From b57e11bf5188a697f30a87e67bdf1b6abfff28e1 Mon Sep 17 00:00:00 2001 From: Cyril Date: Fri, 13 Feb 2026 11:21:44 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20decouple=20pip?= =?UTF-8?q?=20state=20and=20simplify=20pip=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent PiP actions from affecting main panel state and reduce PiP-only noise. --- .../pip/components/DocumentPiPPortal.tsx | 6 +- .../features/pip/components/PipControlBar.tsx | 16 +--- .../src/features/pip/components/PipView.tsx | 7 +- .../src/features/pip/components/RoomPiP.tsx | 16 ++-- .../components/controls/PipLateralMenu.tsx | 66 --------------- .../components/controls/PipOptionsMenu.tsx | 4 +- .../controls/PipOptionsMenuItems.tsx | 32 ++++++++ .../src/features/pip/stores/pipLayoutStore.ts | 17 ++++ .../rooms/livekit/components/SidePanel.tsx | 16 ++-- .../controls/Options/EffectsMenuItem.tsx | 6 +- .../rooms/livekit/hooks/useSidePanel.ts | 81 ++++++++++--------- .../src/features/rooms/livekit/types/panel.ts | 17 ++++ src/frontend/src/stores/layout.ts | 5 +- 13 files changed, 140 insertions(+), 149 deletions(-) delete mode 100644 src/frontend/src/features/pip/components/controls/PipLateralMenu.tsx create mode 100644 src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx create mode 100644 src/frontend/src/features/pip/stores/pipLayoutStore.ts create mode 100644 src/frontend/src/features/rooms/livekit/types/panel.ts diff --git a/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx b/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx index 561c62c5..afbdbda5 100644 --- a/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx +++ b/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDocumentPiP } from '../hooks/useDocumentPiP' import { UNSAFE_PortalProvider } from '@react-aria/overlays' @@ -113,7 +113,7 @@ export const DocumentPiPPortal = ({ height?: number children: React.ReactNode onClose?: () => void -}) => { +}): ReactNode => { const { openPiP, closePiP, pipWindow, isSupported } = useDocumentPiP({ width, height, @@ -189,5 +189,5 @@ export const DocumentPiPPortal = ({ ) }, [children, container]) - return portal + return portal as unknown as ReactNode } diff --git a/src/frontend/src/features/pip/components/PipControlBar.tsx b/src/frontend/src/features/pip/components/PipControlBar.tsx index cb15055b..f63d5ff3 100644 --- a/src/frontend/src/features/pip/components/PipControlBar.tsx +++ b/src/frontend/src/features/pip/components/PipControlBar.tsx @@ -3,12 +3,11 @@ import { AudioDevicesControl } from '@/features/rooms/livekit/components/control import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl' import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle' import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton' -import { ReactionsToggle } from '@/features/rooms/livekit/components/controls/ReactionsToggle' import { SubtitlesToggle } from '@/features/rooms/livekit/components/controls/SubtitlesToggle' import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle' import { OptionsButton } from '@/features/rooms/livekit/components/controls/Options/OptionsButton' import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton' -import { PipLateralMenu } from './controls/PipLateralMenu' +import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle' /** * Compact control bar for the Picture-in-Picture window. @@ -31,9 +30,6 @@ export const PipControlBar = ({ - - - ) @@ -61,13 +57,3 @@ const PipControlsCenter = styled('div', { flex: '1 1 auto', }, }) - -const PipControlsRight = styled('div', { - base: { - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'center', - position: 'absolute', - right: '1.35rem', - }, -}) diff --git a/src/frontend/src/features/pip/components/PipView.tsx b/src/frontend/src/features/pip/components/PipView.tsx index 684bdb7d..5e86a731 100644 --- a/src/frontend/src/features/pip/components/PipView.tsx +++ b/src/frontend/src/features/pip/components/PipView.tsx @@ -7,8 +7,9 @@ import { import { useTracks } from '@livekit/components-react' import { Track } from 'livekit-client' import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' -import { GridLayout } from '@/features/rooms/livekit/components/layout/GridLayout' +import { GridLayout } from '@/features/layout/components/GridLayout' import { SidePanel } from '@/features/rooms/livekit/components/SidePanel' +import { pipLayoutStore } from '../stores/pipLayoutStore' import { PipControlBar } from './PipControlBar' const pickTrackForPip = ( @@ -16,7 +17,7 @@ const pickTrackForPip = ( ): TrackReferenceOrPlaceholder | undefined => { // Prefer screen share when present; otherwise fallback to first available track. const screenShareTrack = tracks - .filter(isTrackReference) + .filter((track) => isTrackReference(track)) .find((track) => track.publication.source === Track.Source.ScreenShare) if (screenShareTrack) return screenShareTrack @@ -60,7 +61,7 @@ export const PipView = () => { {/* Compact control bar for PiP; extend here when adding more actions. */} {/* Side panel (effects, settings, etc.) opens within PiP window. */} - + ) } diff --git a/src/frontend/src/features/pip/components/RoomPiP.tsx b/src/frontend/src/features/pip/components/RoomPiP.tsx index 9cbddfba..9d3387b6 100644 --- a/src/frontend/src/features/pip/components/RoomPiP.tsx +++ b/src/frontend/src/features/pip/components/RoomPiP.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + import { DocumentPiPPortal } from './DocumentPiPPortal' import { PipView } from './PipView' import { useRoomPiP } from '../hooks/useRoomPiP' @@ -5,13 +7,15 @@ import { useRoomPiP } from '../hooks/useRoomPiP' /** * Wrapper that mounts the PiP UI when room-level PiP state is enabled. * Bridges Valtio-backed PiP state with DocumentPiPPortal and PipView rendering. + * PiP panel state is decoupled via explicit pipLayoutStore injection. */ -export const RoomPiP = () => { +export const RoomPiP = (): ReactNode => { const { isOpen, close } = useRoomPiP() - return ( - - - - ) + const portal = DocumentPiPPortal({ + isOpen, + onClose: close, + children: , + }) + return portal as ReactNode } diff --git a/src/frontend/src/features/pip/components/controls/PipLateralMenu.tsx b/src/frontend/src/features/pip/components/controls/PipLateralMenu.tsx deleted file mode 100644 index c2b7e2f0..00000000 --- a/src/frontend/src/features/pip/components/controls/PipLateralMenu.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { css } from '@/styled-system/css' -import { useState } from 'react' -import { Dialog, DialogTrigger } from 'react-aria-components' -import { Button } from '@/primitives' -import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' -import { useTranslation } from 'react-i18next' -import { StyledPopover } from '@/primitives/Popover' -import { useOverlayBoundaryElement } from '@/primitives/useOverlayPortalContainer' -import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle' -import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle' -import { ToolsToggle } from '@/features/rooms/livekit/components/controls/ToolsToggle' -import { InfoToggle } from '@/features/rooms/livekit/components/controls/InfoToggle' -import { AdminToggle } from '@/features/rooms/livekit/components/AdminToggle' - -const NavigationControls = ({ onPress }: { onPress?: () => void }) => ( - <> - - - - - - -) - -/** - * PiP chevron menu that exposes Info/Chat/Participants/Tools/Admin. - */ -export const PipLateralMenu = () => { - const { t } = useTranslation('rooms') - const [isOpen, setIsOpen] = useState(false) - const boundaryElement = useOverlayBoundaryElement() - - const handlePress = () => setIsOpen(!isOpen) - const handleClose = () => setIsOpen(false) - - return ( - - - - - - - - - ) -} diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx index c320a7b8..93c350e1 100644 --- a/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react' import { RiMoreFill } from '@remixicon/react' import { Box, Button } from '@/primitives' import { css } from '@/styled-system/css' -import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems' +import { PipOptionsMenuItems } from './PipOptionsMenuItems' type PipOptionsMenuProps = { wrapperRef: React.RefObject @@ -77,7 +77,7 @@ export const PipOptionsMenu = ({ })} > - + )} diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx new file mode 100644 index 00000000..e8ad1b74 --- /dev/null +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx @@ -0,0 +1,32 @@ +import { Menu as RACMenu, MenuSection } from 'react-aria-components' +import { Separator } from '@/primitives/Separator' +import { SettingsMenuItem } from '@/features/rooms/livekit/components/controls/Options/SettingsMenuItem' +import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem' +import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem' +import { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem' +import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem' +import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore' + +/** + * PiP options menu items: excludes transcript, screen recording, and full screen + * (those features are not relevant in the PiP window context). + */ +export const PipOptionsMenuItems = () => ( + + + + + + + + + + + + +) diff --git a/src/frontend/src/features/pip/stores/pipLayoutStore.ts b/src/frontend/src/features/pip/stores/pipLayoutStore.ts new file mode 100644 index 00000000..e67253a6 --- /dev/null +++ b/src/frontend/src/features/pip/stores/pipLayoutStore.ts @@ -0,0 +1,17 @@ +import { proxy } from 'valtio' +import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel' + +type PipLayoutState = { + activePanelId: PanelId | null + activeSubPanelId: SubPanelId | null +} + +/** + * Separate layout store for the PiP window. + * Decouples PiP side panel state from the main view so opening Chat/Info/etc. + * in PiP does not affect the main window and vice versa. + */ +export const pipLayoutStore = proxy({ + activePanelId: null, + activeSubPanelId: null, +}) diff --git a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx index 900ca9f2..efd80688 100644 --- a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx +++ b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx @@ -1,4 +1,3 @@ -import { layoutStore } from '@/stores/layout' import { css } from '@/styled-system/css' import { Heading } from 'react-aria-components' import { text } from '@/primitives/Text' @@ -6,7 +5,7 @@ import { Button, Div } from '@/primitives' import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { ParticipantsList } from './controls/Participants/ParticipantsList' -import { useSidePanel } from '../hooks/useSidePanel' +import { type SidePanelStore, useSidePanel } from '../hooks/useSidePanel' import { ReactNode } from 'react' import { Chat } from '../prefabs/Chat' import { Effects } from './effects/Effects' @@ -144,7 +143,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => ( {keepAlive || isOpen ? children : null} ) -export const SidePanel = () => { +export const SidePanel = ({ store }: { store?: SidePanelStore }) => { const { activePanelId, isParticipantsOpen, @@ -156,7 +155,9 @@ export const SidePanel = () => { isInfoOpen, isSubPanelOpen, activeSubPanelId, - } = useSidePanel() + closePanel, + goBack, + } = useSidePanel(store) const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const title = t(`heading.${activeSubPanelId || activePanelId}`) @@ -166,10 +167,7 @@ export const SidePanel = () => { { - layoutStore.activePanelId = null - layoutStore.activeSubPanelId = null - }} + onClose={closePanel} closeButtonTooltip={t('closeButton', { content: t(`content.${activeSubPanelId || activePanelId}`), })} @@ -177,7 +175,7 @@ export const SidePanel = () => { isSubmenu={isSubPanelOpen} isReactionToolbarOpen={isReactionToolbarOpen} backButtonLabel={t('backToTools')} - onBack={() => (layoutStore.activeSubPanelId = null)} + onBack={goBack} > diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Options/EffectsMenuItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Options/EffectsMenuItem.tsx index 98a2e7d3..4ee37209 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Options/EffectsMenuItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Options/EffectsMenuItem.tsx @@ -2,11 +2,11 @@ import { RiImageCircleAiFill } from '@remixicon/react' import { MenuItem } from 'react-aria-components' import { useTranslation } from 'react-i18next' import { menuRecipe } from '@/primitives/menuRecipe' -import { useSidePanel } from '../../../hooks/useSidePanel' +import { type SidePanelStore, useSidePanel } from '../../../hooks/useSidePanel' -export const EffectsMenuItem = () => { +export const EffectsMenuItem = ({ store }: { store?: SidePanelStore }) => { const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) - const { toggleEffects } = useSidePanel() + const { toggleEffects } = useSidePanel(store) return ( { - const layoutSnap = useSnapshot(layoutStore) +export const useSidePanel = (store: SidePanelStore = layoutStore) => { + const layoutSnap = useSnapshot(store) const activePanelId = layoutSnap.activePanelId const activeSubPanelId = layoutSnap.activeSubPanelId - const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS - const isEffectsOpen = activePanelId == PanelId.EFFECTS - const isChatOpen = activePanelId == PanelId.CHAT - const isToolsOpen = activePanelId == PanelId.TOOLS - const isAdminOpen = activePanelId == PanelId.ADMIN - const isInfoOpen = activePanelId == PanelId.INFO - const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT - const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING + const isParticipantsOpen = activePanelId === PanelId.PARTICIPANTS + const isEffectsOpen = activePanelId === PanelId.EFFECTS + const isChatOpen = activePanelId === PanelId.CHAT + const isToolsOpen = activePanelId === PanelId.TOOLS + const isAdminOpen = activePanelId === PanelId.ADMIN + const isInfoOpen = activePanelId === PanelId.INFO + const isTranscriptOpen = activeSubPanelId === SubPanelId.TRANSCRIPT + const isScreenRecordingOpen = activeSubPanelId === SubPanelId.SCREEN_RECORDING const isSidePanelOpen = !!activePanelId const isSubPanelOpen = !!activeSubPanelId const toggleAdmin = () => { - layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN - if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null + store.activePanelId = isAdminOpen ? null : PanelId.ADMIN + if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null } const toggleParticipants = () => { - layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS - if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null + store.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS + if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null } const toggleChat = () => { - layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT - if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null + store.activePanelId = isChatOpen ? null : PanelId.CHAT + if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null } const toggleEffects = () => { - layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS - if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null + store.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS + if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null } const toggleTools = () => { - layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS - if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null + store.activePanelId = isToolsOpen ? null : PanelId.TOOLS + if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null } const toggleInfo = () => { - layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO - if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null + store.activePanelId = isInfoOpen ? null : PanelId.INFO + if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null } const openTranscript = () => { - layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT - layoutStore.activePanelId = PanelId.TOOLS + store.activeSubPanelId = SubPanelId.TRANSCRIPT + store.activePanelId = PanelId.TOOLS } const openScreenRecording = () => { - layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING - layoutStore.activePanelId = PanelId.TOOLS + store.activeSubPanelId = SubPanelId.SCREEN_RECORDING + store.activePanelId = PanelId.TOOLS + } + + const closePanel = () => { + store.activePanelId = null + store.activeSubPanelId = null + } + + const goBack = () => { + store.activeSubPanelId = null } return { @@ -82,6 +85,8 @@ export const useSidePanel = () => { toggleInfo, openTranscript, openScreenRecording, + closePanel, + goBack, isSubPanelOpen, isChatOpen, isParticipantsOpen, diff --git a/src/frontend/src/features/rooms/livekit/types/panel.ts b/src/frontend/src/features/rooms/livekit/types/panel.ts new file mode 100644 index 00000000..426f8884 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/types/panel.ts @@ -0,0 +1,17 @@ +/** + * Panel identifiers for the side panel (Info, Chat, Participants, etc.). + * Extracted to avoid circular dependencies between layout store and useSidePanel. + */ +export enum PanelId { + PARTICIPANTS = 'participants', + EFFECTS = 'effects', + CHAT = 'chat', + TOOLS = 'tools', + ADMIN = 'admin', + INFO = 'info', +} + +export enum SubPanelId { + TRANSCRIPT = 'transcript', + SCREEN_RECORDING = 'screenRecording', +} diff --git a/src/frontend/src/stores/layout.ts b/src/frontend/src/stores/layout.ts index f69b010a..0287d4b7 100644 --- a/src/frontend/src/stores/layout.ts +++ b/src/frontend/src/stores/layout.ts @@ -1,8 +1,5 @@ import { proxy } from 'valtio' -import { - PanelId, - SubPanelId, -} from '@/features/rooms/livekit/hooks/useSidePanel' +import { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel' type State = { showHeader: boolean