🐛(frontend) fix pip controls responsive overflow in realtime

Use PiP document ResizeObserver so controls hide into "..." while resizing.
This commit is contained in:
Cyril
2026-04-08 10:59:13 +02:00
parent 6b566831a2
commit 1e21274237
4 changed files with 214 additions and 78 deletions
@@ -1,37 +1,110 @@
import { styled } from '@/styled-system/jsx'
import { useRef, useMemo, useState, useEffect, useCallback } from 'react'
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
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 { 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 { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { PipOptionsMenu } from './controls/PipOptionsMenu'
export type CollapsibleControl =
| 'hand'
| 'subtitles'
| 'screenShare'
| 'reactions'
const COLLAPSE_ORDER: CollapsibleControl[] = [
'hand',
'subtitles',
'screenShare',
'reactions',
]
const BUTTON_SLOT = 50
const ESSENTIAL_WIDTH = 260
function getHiddenControls(
containerWidth: number,
showScreenShare: boolean
): Set<CollapsibleControl> {
const hidden = new Set<CollapsibleControl>()
if (containerWidth <= 0) return hidden
const collapsible = showScreenShare
? COLLAPSE_ORDER
: COLLAPSE_ORDER.filter((c) => c !== 'screenShare')
const available = containerWidth - ESSENTIAL_WIDTH
const maxVisible = Math.max(0, Math.floor(available / BUTTON_SLOT))
for (let i = 0; i < collapsible.length - maxVisible; i++) {
hidden.add(collapsible[i])
}
return hidden
}
/**
* Compact control bar for the Picture-in-Picture window.
* Centralizes all PiP controls (devices, reactions, screen share, options, etc.) in one reusable component.
* ResizeObserver that works inside the PiP document context
* (the global singleton from the main window cannot observe PiP elements).
*/
function usePipSize(ref: React.RefObject<HTMLElement | null>) {
const [width, setWidth] = useState(0)
const measure = useCallback(() => {
if (ref.current) setWidth(ref.current.getBoundingClientRect().width)
}, [ref])
useEffect(() => {
const el = ref.current
if (!el) return
measure()
const RO =
el.ownerDocument.defaultView?.ResizeObserver ?? window.ResizeObserver
const observer = new RO((entries) => {
const entry = entries[0]
if (entry) setWidth(entry.contentRect.width)
})
observer.observe(el)
return () => observer.disconnect()
}, [ref, measure])
return width
}
export const PipControlBar = ({
showScreenShare,
}: {
showScreenShare: boolean
}) => (
<PipControls>
<PipControlsCenter>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
<ReactionsToggle />
{showScreenShare && <ScreenShareToggle />}
<SubtitlesToggle />
<HandToggle />
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</PipControlsCenter>
</PipControls>
)
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const width = usePipSize(containerRef)
const hidden = useMemo(
() => getHiddenControls(width, showScreenShare),
[width, showScreenShare]
)
return (
<PipControls ref={containerRef}>
<PipControlsCenter>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
{!hidden.has('reactions') && <ReactionsToggle />}
{showScreenShare && !hidden.has('screenShare') && <ScreenShareToggle />}
{!hidden.has('subtitles') && <SubtitlesToggle />}
{!hidden.has('hand') && <HandToggle />}
<PipOptionsMenu overflowControls={hidden} />
<LeaveButton />
<StartMediaButton />
</PipControlsCenter>
</PipControls>
)
}
const PipControls = styled('div', {
base: {
@@ -50,7 +123,7 @@ const PipControls = styled('div', {
const PipControlsCenter = styled('div', {
base: {
display: 'flex',
flexWrap: 'wrap',
flexWrap: 'nowrap',
justifyContent: 'center',
alignItems: 'center',
gap: '0.4rem',
@@ -1,27 +1,26 @@
import React, { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useTranslation } from 'react-i18next'
import type { CollapsibleControl } from '../PipControlBar'
type PipOptionsMenuProps = {
wrapperRef: React.RefObject<HTMLDivElement>
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
label: string
overflowControls?: Set<CollapsibleControl>
}
/**
* PiP-specific options menu with absolute positioning for correct alignment in PiP window.
* Renders locally (unlike standard Menu) and closes automatically on item click or outside click.
* Overflow controls from the toolbar are rendered as additional menu items.
*/
export const PipOptionsMenu = ({
wrapperRef,
isOpen,
setIsOpen,
label,
}: PipOptionsMenuProps) => {
// Close menu when a menu item action completes (e.g., transcription, effects, recording).
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
const label = t('options.buttonLabel')
useEffect(() => {
if (!isOpen) return
const doc = wrapperRef.current?.ownerDocument ?? document
@@ -31,12 +30,9 @@ export const PipOptionsMenu = ({
const wrapper = wrapperRef.current
if (!wrapper || !target) return
// Don't close if clicking the trigger button
if (wrapper.querySelector('button')?.contains(target)) return
// Close if clicking a menu item (action will have fired)
if (target.closest('[role="menuitem"]')) {
// Use requestAnimationFrame to ensure action completes first, without visible delay
requestAnimationFrame(() => {
setIsOpen(false)
})
@@ -47,7 +43,7 @@ export const PipOptionsMenu = ({
return () => {
doc.removeEventListener('click', handleMenuItemClick, true)
}
}, [isOpen, setIsOpen, wrapperRef])
}, [isOpen])
return (
<div
@@ -77,7 +73,7 @@ export const PipOptionsMenu = ({
})}
>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems />
<PipOptionsMenuItems overflowControls={overflowControls} />
</Box>
</div>
)}
@@ -1,4 +1,11 @@
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
import { Menu as RACMenu, MenuItem, MenuSection } from 'react-aria-components'
import {
RiHand,
RiClosedCaptioningLine,
RiArrowUpLine,
RiEmotionLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
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'
@@ -6,27 +13,105 @@ import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Op
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'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { useAreSubtitlesAvailable } from '@/features/subtitle/hooks/useAreSubtitlesAvailable'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import type { CollapsibleControl } from '../PipControlBar'
/**
* 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>
)
type PipOptionsMenuItemsProps = {
overflowControls?: Set<CollapsibleControl>
}
export const PipOptionsMenuItems = ({
overflowControls,
}: PipOptionsMenuItemsProps) => {
const { t } = useTranslation('rooms')
const hasOverflow = overflowControls && overflowControls.size > 0
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
{hasOverflow && (
<>
<MenuSection>
<OverflowItems overflowControls={overflowControls} t={t} />
</MenuSection>
<Separator />
</>
)}
<MenuSection>
<PictureInPictureMenuItem />
<EffectsMenuItem store={pipLayoutStore} />
</MenuSection>
<Separator />
<MenuSection>
<SupportMenuItem />
<FeedbackMenuItem />
<SettingsMenuItem />
</MenuSection>
</RACMenu>
)
}
const OverflowItems = ({
overflowControls,
t,
}: {
overflowControls: Set<CollapsibleControl>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any
}) => {
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const { areSubtitlesOpen, toggleSubtitles } = useSubtitles()
const areSubtitlesAvailable = useAreSubtitlesAvailable()
const { toggle: toggleReactions } = useReactionsToolbar()
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
return (
<>
{overflowControls.has('reactions') && (
<MenuItem onAction={toggleReactions} className={itemClass}>
<RiEmotionLine size={20} />
{t('controls.reactions.button')}
</MenuItem>
)}
{overflowControls.has('screenShare') && (
<MenuItem
onAction={() => {
/* screen share requires track toggle, handled externally */
}}
className={itemClass}
>
<RiArrowUpLine size={20} />
{t('controls.screenShare.start')}
</MenuItem>
)}
{overflowControls.has('subtitles') && areSubtitlesAvailable && (
<MenuItem onAction={toggleSubtitles} className={itemClass}>
<RiClosedCaptioningLine size={20} />
{areSubtitlesOpen
? t('controls.subtitles.open')
: t('controls.subtitles.closed')}
</MenuItem>
)}
{overflowControls.has('hand') && (
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
<RiHand size={20} />
{isHandRaised
? t('controls.hand.lower')
: t('controls.hand.raise')}
</MenuItem>
)}
</>
)
}
@@ -2,27 +2,9 @@ import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems'
import { useOverlayPortalContainer } from '@/primitives/useOverlayPortalContainer'
import { useRef, useState } from 'react'
import { PipOptionsMenu } from '@/features/pip/components/controls/PipOptionsMenu'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const portalContainer = useOverlayPortalContainer()
const isInPiP = portalContainer && portalContainer.ownerDocument !== document
const [isOpen, setIsOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
if (isInPiP) {
return (
<PipOptionsMenu
wrapperRef={wrapperRef}
isOpen={isOpen}
setIsOpen={setIsOpen}
label={t('options.buttonLabel')}
/>
)
}
return (
<Menu variant="dark">