♻️(frontend) decouple pip state and simplify pip options

Prevent PiP actions from affecting main panel state and reduce PiP-only noise.
This commit is contained in:
Cyril
2026-02-13 11:21:44 +01:00
parent 2378af9e9b
commit b57e11bf51
13 changed files with 140 additions and 149 deletions
@@ -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
}
@@ -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 = ({
<LeaveButton />
<StartMediaButton />
</PipControlsCenter>
<PipControlsRight>
<PipLateralMenu />
</PipControlsRight>
</PipControls>
)
@@ -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',
},
})
@@ -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. */}
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
{/* Side panel (effects, settings, etc.) opens within PiP window. */}
<SidePanel />
<SidePanel store={pipLayoutStore} />
</PipContainer>
)
}
@@ -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 (
<DocumentPiPPortal isOpen={isOpen} onClose={close}>
<PipView />
</DocumentPiPPortal>
)
const portal = DocumentPiPPortal({
isOpen,
onClose: close,
children: <PipView />,
})
return portal as ReactNode
}
@@ -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 }) => (
<>
<InfoToggle onPress={onPress} tooltipType="delayed" />
<ChatToggle onPress={onPress} tooltipType="delayed" />
<ParticipantsToggle onPress={onPress} tooltipType="delayed" />
<ToolsToggle onPress={onPress} tooltipType="delayed" />
<AdminToggle onPress={onPress} tooltipType="delayed" />
</>
)
/**
* 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 (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
id="pip-controlbar-more-options-trigger"
square
variant="secondaryDark"
aria-label={t('controls.moreOptions')}
tooltip={t('controls.moreOptions')}
onPress={handlePress}
>
{isOpen ? <RiArrowDownSLine /> : <RiArrowUpSLine />}
</Button>
<StyledPopover placement="top" boundaryElement={boundaryElement}>
<Dialog
className={css({
width: '65px',
backgroundColor: 'primaryDark.50',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
borderRadius: '4px',
paddingTop: '10px',
gap: '0.5rem',
})}
>
<NavigationControls onPress={handleClose} />
</Dialog>
</StyledPopover>
</DialogTrigger>
)
}
@@ -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<HTMLDivElement>
@@ -77,7 +77,7 @@ export const PipOptionsMenu = ({
})}
>
<Box size="sm" type="popover" variant="dark">
<OptionsMenuItems />
<PipOptionsMenuItems />
</Box>
</div>
)}
@@ -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 = () => (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
<MenuSection>
<PictureInPictureMenuItem />
<EffectsMenuItem store={pipLayoutStore} />
</MenuSection>
<Separator />
<MenuSection>
<SupportMenuItem />
<FeedbackMenuItem />
<SettingsMenuItem />
</MenuSection>
</RACMenu>
)
@@ -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<PipLayoutState>({
activePanelId: null,
activeSubPanelId: null,
})
@@ -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}
</div>
)
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 = () => {
<StyledSidePanel
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
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}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
@@ -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 (
<MenuItem
@@ -1,74 +1,77 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { PanelId, SubPanelId } from '../types/panel'
export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TOOLS = 'tools',
ADMIN = 'admin',
INFO = 'info',
export { PanelId, SubPanelId }
export type SidePanelStore = {
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
}
export enum SubPanelId {
TRANSCRIPT = 'transcript',
SCREEN_RECORDING = 'screenRecording',
}
export const useSidePanel = () => {
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,
@@ -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',
}
+1 -4
View File
@@ -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