mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
🐛(frontend) fix pip controls responsive overflow in realtime
Use PiP document ResizeObserver so controls hide into "..." while resizing.
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user