(frontend) make the PiP control bar collapsible for responsiveness

The PiP window can be resized to a wide range of dimensions, so
the control bar needs to adapt. Introduce a collapsible mechanism
that hides or condenses controls as available width shrinks,
keeping the UX usable at small sizes.

Original works by @ovgdd

Co-authored-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
lebaudantoine
2026-05-22 14:42:55 +02:00
committed by aleb_the_flash
parent 60828ed895
commit dee1e46173
3 changed files with 121 additions and 8 deletions
@@ -8,18 +8,66 @@ import { HandToggle } from '@/features/rooms/livekit/components/controls/HandTog
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton' import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle' import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { PipOptionsMenu } from './controls/PipOptionsMenu' import { PipOptionsMenu } from './controls/PipOptionsMenu'
import { usePipElementSize } from '../hooks/usePipElementSize'
import { useMemo, useRef } from 'react'
export const CollapsibleControls = {
HAND: 'hand',
SCREEN_SHARE: 'screenShare',
REACTIONS: 'reactions',
} as const
export type CollapsibleControl =
(typeof CollapsibleControls)[keyof typeof CollapsibleControls]
const COLLAPSE_ORDER: CollapsibleControl[] = [
CollapsibleControls.HAND,
CollapsibleControls.SCREEN_SHARE,
CollapsibleControls.REACTIONS,
]
const BUTTON_SLOT = 50
const ESSENTIAL_WIDTH = 260
const 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 !== CollapsibleControls.SCREEN_SHARE)
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
}
export const PipControlBar = ({ export const PipControlBar = ({
showScreenShare, showScreenShare,
}: { }: {
showScreenShare: boolean showScreenShare: boolean
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width } = usePipElementSize(containerRef)
const { t } = useTranslation('rooms', { const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture', keyPrefix: 'pictureInPicture',
}) })
const hidden = useMemo(
() => getHiddenControls(width, showScreenShare),
[width, showScreenShare]
)
return ( return (
<PipControls <PipControls
ref={containerRef}
id="pip-control-bar" id="pip-control-bar"
role="toolbar" role="toolbar"
aria-label={t('controlBar')} aria-label={t('controlBar')}
@@ -27,11 +75,13 @@ export const PipControlBar = ({
<PipControlsCenter> <PipControlsCenter>
<AudioDevicesControl hideMenu /> <AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu /> <VideoDeviceControl hideMenu />
<ReactionsToggle /> {!hidden.has(CollapsibleControls.REACTIONS) && <ReactionsToggle />}
{showScreenShare && <ScreenShareToggle />} {showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
<HandToggle /> <ScreenShareToggle />
)}
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
<StartMediaButton /> <StartMediaButton />
<PipOptionsMenu /> <PipOptionsMenu overflowControls={hidden} />
<LeaveButton /> <LeaveButton />
</PipControlsCenter> </PipControlsCenter>
</PipControls> </PipControls>
@@ -6,7 +6,11 @@ import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PipOptionsMenuItems } from './PipOptionsMenuItems' import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useDismissOnEscape } from '../../hooks/useDismissOnEscape' import { useDismissOnEscape } from '../../hooks/useDismissOnEscape'
import { CollapsibleControl } from '@/features/pip/components/PipControlBar'
type PipOptionsMenuProps = {
overflowControls: Set<CollapsibleControl>
}
/** /**
* PiP-native options menu. * PiP-native options menu.
* *
@@ -35,7 +39,7 @@ import { useDismissOnEscape } from '../../hooks/useDismissOnEscape'
* *
* So in PiP we replace the primitive with this component. * So in PiP we replace the primitive with this component.
*/ */
export const PipOptionsMenu = () => { export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
@@ -115,7 +119,7 @@ export const PipOptionsMenu = () => {
{/* eslint-disable-next-line jsx-a11y/no-autofocus */} {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus> <FocusScope autoFocus>
<Box size="sm" type="popover" variant="dark"> <Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems /> <PipOptionsMenuItems overflowControls={overflowControls} />
</Box> </Box>
</FocusScope> </FocusScope>
</div> </div>
@@ -1,7 +1,37 @@
import { Menu as RACMenu } from 'react-aria-components' import { Menu as RACMenu, MenuItem } from 'react-aria-components'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem' import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
import {
CollapsibleControl,
CollapsibleControls,
} from '../PipControlBar'
import { RiArrowUpLine, RiEmotionLine, RiHand } from '@remixicon/react'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRoomContext, useTrackToggle } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useTranslation } from 'react-i18next'
import { Track } from 'livekit-client'
type PipOverflowItemsProps = {
overflowControls: Set<CollapsibleControl>
}
export const PipOptionsMenuItems = ({
overflowControls,
}: PipOverflowItemsProps) => {
const { t } = useTranslation('rooms')
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const { buttonProps: screenShareProps, enabled: isScreenSharing } =
useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: { audio: true, selfBrowserSurface: 'include' },
})
const { toggle: toggleReactions } = useReactionsToolbar()
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
export const PipOptionsMenuItems = () => {
return ( return (
<RACMenu <RACMenu
style={{ style={{
@@ -10,6 +40,35 @@ export const PipOptionsMenuItems = () => {
}} }}
> >
<PictureInPictureMenuItem /> <PictureInPictureMenuItem />
{overflowControls.has(CollapsibleControls.REACTIONS) && (
<MenuItem onAction={toggleReactions} className={itemClass}>
<RiEmotionLine size={20} />
{t('controls.reactions.button')}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.SCREEN_SHARE) && (
<MenuItem
onAction={() =>
screenShareProps.onClick?.(
{} as React.MouseEvent<HTMLButtonElement>
)
}
className={itemClass}
>
<RiArrowUpLine size={20} />
{t(
isScreenSharing
? 'controls.screenShare.stop'
: 'controls.screenShare.start'
)}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.HAND) && (
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
<RiHand size={20} />
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
</MenuItem>
)}
</RACMenu> </RACMenu>
) )
} }