From e897a1635a903165dca80e8d324e57e7bc82264b Mon Sep 17 00:00:00 2001 From: Ovgodd Date: Thu, 8 Oct 2026 16:08:25 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20let=20the=20local=20tile?= =?UTF-8?q?=20leave=20or=20keep=20the=20corner=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1:1 local tile stays corner or returns to grid via menu; changes are announced --- .../layout/components/OneToOneFocusLayout.tsx | 49 +++++++-- .../layout/components/StageLayout.tsx | 34 +++--- .../features/layout/utils/selfTileLayout.ts | 32 ++++++ .../ParticipantTileFocus.tsx | 20 +++- .../participantTileFocus/SelfTileMenu.tsx | 100 ++++++++++++++++++ src/frontend/src/locales/de/rooms.json | 13 ++- src/frontend/src/locales/en/rooms.json | 13 ++- src/frontend/src/locales/fr/rooms.json | 13 ++- src/frontend/src/locales/nl/rooms.json | 13 ++- src/frontend/src/primitives/Menu.tsx | 4 +- src/frontend/src/stores/layout.ts | 32 ++++++ 11 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 src/frontend/src/features/layout/utils/selfTileLayout.ts create mode 100644 src/frontend/src/features/participantTile/components/participantTileFocus/SelfTileMenu.tsx diff --git a/src/frontend/src/features/layout/components/OneToOneFocusLayout.tsx b/src/frontend/src/features/layout/components/OneToOneFocusLayout.tsx index 7cb9ed960..097b12117 100644 --- a/src/frontend/src/features/layout/components/OneToOneFocusLayout.tsx +++ b/src/frontend/src/features/layout/components/OneToOneFocusLayout.tsx @@ -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 ( - {mainTrack && ( - + {resolvedMainTracks.length === 1 && ( + )} + {resolvedMainTracks.length > 1 && ( + + + + + + )} {thumbnailTrack && ( - + { 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 ? ( ) : !pinnedTrackRef ? (
diff --git a/src/frontend/src/features/layout/utils/selfTileLayout.ts b/src/frontend/src/features/layout/utils/selfTileLayout.ts new file mode 100644 index 000000000..621b8d648 --- /dev/null +++ b/src/frontend/src/features/layout/utils/selfTileLayout.ts @@ -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, + } +} diff --git a/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx b/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx index 029a7f590..9919f4e8f 100644 --- a/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx +++ b/src/frontend/src/features/participantTile/components/participantTileFocus/ParticipantTileFocus.tsx @@ -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 + 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(null) @@ -63,8 +68,7 @@ const FadeOverlay = ({ } }, [tileRef]) - const isVisible = hasKeyboardFocus || active - + const isVisible = hasKeyboardFocus || active || forceVisible return (
+
)} + {showSelfTileMenu && }
diff --git a/src/frontend/src/features/participantTile/components/participantTileFocus/SelfTileMenu.tsx b/src/frontend/src/features/participantTile/components/participantTileFocus/SelfTileMenu.tsx new file mode 100644 index 000000000..3b11f8262 --- /dev/null +++ b/src/frontend/src/features/participantTile/components/participantTileFocus/SelfTileMenu.tsx @@ -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 ( + + + + {isInCorner && ( + { + toggleSelfTileMinimized() + announce( + isMinimized + ? t('announcements.expanded') + : t('announcements.reduced') + ) + }} + > + {isMinimized ? ( + + ) : ( + + )} + {isMinimized ? t('expand') : t('reduce')} + + )} + { + if (isInCorner) { + hideSelfTile() + announce(t('announcements.hidden')) + return + } + showSelfTile() + announce(t('announcements.shown')) + }} + > + {isInCorner ? ( + + ) : ( + + )} + {isInCorner ? t('hideTile') : t('showTile')} + + + + ) +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index f9f714fa0..8470545d1 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -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", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 35f5d4235..1bc2f65dc 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index bbfb5e06d..1728b6244 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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", diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 07db7bfc1..70fb30d48 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -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", diff --git a/src/frontend/src/primitives/Menu.tsx b/src/frontend/src/primitives/Menu.tsx index 86eba76d4..5e9f27315 100644 --- a/src/frontend/src/primitives/Menu.tsx +++ b/src/frontend/src/primitives/Menu.tsx @@ -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 ( - + {trigger} diff --git a/src/frontend/src/stores/layout.ts b/src/frontend/src/stores/layout.ts index 44fea86d9..8fdd4a654 100644 --- a/src/frontend/src/stores/layout.ts +++ b/src/frontend/src/stores/layout.ts @@ -7,6 +7,14 @@ import { TrackReferenceOrPlaceholder } from '@livekit/components-core' type PinnedTrackRef = ReturnType> +/** + * 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({ @@ -25,6 +35,8 @@ export const layoutStore = proxy({ 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