mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 03:59:07 +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 { styled } from '@/styled-system/jsx'
|
||||||
|
import { useRef, useMemo, useState, useEffect, useCallback } from 'react'
|
||||||
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
||||||
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
|
||||||
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
|
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
|
||||||
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
|
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
|
||||||
import { SubtitlesToggle } from '@/features/rooms/livekit/components/controls/SubtitlesToggle'
|
import { SubtitlesToggle } from '@/features/rooms/livekit/components/controls/SubtitlesToggle'
|
||||||
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
|
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 { 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'
|
||||||
|
|
||||||
|
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.
|
* ResizeObserver that works inside the PiP document context
|
||||||
* Centralizes all PiP controls (devices, reactions, screen share, options, etc.) in one reusable component.
|
* (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 = ({
|
export const PipControlBar = ({
|
||||||
showScreenShare,
|
showScreenShare,
|
||||||
}: {
|
}: {
|
||||||
showScreenShare: boolean
|
showScreenShare: boolean
|
||||||
}) => (
|
}) => {
|
||||||
<PipControls>
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
<PipControlsCenter>
|
const width = usePipSize(containerRef)
|
||||||
<AudioDevicesControl hideMenu />
|
|
||||||
<VideoDeviceControl hideMenu />
|
const hidden = useMemo(
|
||||||
<ReactionsToggle />
|
() => getHiddenControls(width, showScreenShare),
|
||||||
{showScreenShare && <ScreenShareToggle />}
|
[width, showScreenShare]
|
||||||
<SubtitlesToggle />
|
)
|
||||||
<HandToggle />
|
|
||||||
<OptionsButton />
|
return (
|
||||||
<LeaveButton />
|
<PipControls ref={containerRef}>
|
||||||
<StartMediaButton />
|
<PipControlsCenter>
|
||||||
</PipControlsCenter>
|
<AudioDevicesControl hideMenu />
|
||||||
</PipControls>
|
<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', {
|
const PipControls = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -50,7 +123,7 @@ const PipControls = styled('div', {
|
|||||||
const PipControlsCenter = styled('div', {
|
const PipControlsCenter = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'nowrap',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.4rem',
|
gap: '0.4rem',
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import React, { useEffect } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { RiMoreFill } from '@remixicon/react'
|
import { RiMoreFill } from '@remixicon/react'
|
||||||
import { Box, Button } from '@/primitives'
|
import { Box, Button } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
|
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { CollapsibleControl } from '../PipControlBar'
|
||||||
|
|
||||||
type PipOptionsMenuProps = {
|
type PipOptionsMenuProps = {
|
||||||
wrapperRef: React.RefObject<HTMLDivElement>
|
overflowControls?: Set<CollapsibleControl>
|
||||||
isOpen: boolean
|
|
||||||
setIsOpen: (isOpen: boolean) => void
|
|
||||||
label: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PiP-specific options menu with absolute positioning for correct alignment in PiP window.
|
* 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.
|
* 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 = ({
|
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
||||||
wrapperRef,
|
const { t } = useTranslation('rooms')
|
||||||
isOpen,
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
setIsOpen,
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
label,
|
const label = t('options.buttonLabel')
|
||||||
}: PipOptionsMenuProps) => {
|
|
||||||
// Close menu when a menu item action completes (e.g., transcription, effects, recording).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const doc = wrapperRef.current?.ownerDocument ?? document
|
const doc = wrapperRef.current?.ownerDocument ?? document
|
||||||
@@ -31,12 +30,9 @@ export const PipOptionsMenu = ({
|
|||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper || !target) return
|
if (!wrapper || !target) return
|
||||||
|
|
||||||
// Don't close if clicking the trigger button
|
|
||||||
if (wrapper.querySelector('button')?.contains(target)) return
|
if (wrapper.querySelector('button')?.contains(target)) return
|
||||||
|
|
||||||
// Close if clicking a menu item (action will have fired)
|
|
||||||
if (target.closest('[role="menuitem"]')) {
|
if (target.closest('[role="menuitem"]')) {
|
||||||
// Use requestAnimationFrame to ensure action completes first, without visible delay
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
})
|
})
|
||||||
@@ -47,7 +43,7 @@ export const PipOptionsMenu = ({
|
|||||||
return () => {
|
return () => {
|
||||||
doc.removeEventListener('click', handleMenuItemClick, true)
|
doc.removeEventListener('click', handleMenuItemClick, true)
|
||||||
}
|
}
|
||||||
}, [isOpen, setIsOpen, wrapperRef])
|
}, [isOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -77,7 +73,7 @@ export const PipOptionsMenu = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Box size="sm" type="popover" variant="dark">
|
<Box size="sm" type="popover" variant="dark">
|
||||||
<PipOptionsMenuItems />
|
<PipOptionsMenuItems overflowControls={overflowControls} />
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</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 { Separator } from '@/primitives/Separator'
|
||||||
import { SettingsMenuItem } from '@/features/rooms/livekit/components/controls/Options/SettingsMenuItem'
|
import { SettingsMenuItem } from '@/features/rooms/livekit/components/controls/Options/SettingsMenuItem'
|
||||||
import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem'
|
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 { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem'
|
||||||
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
||||||
import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore'
|
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'
|
||||||
|
|
||||||
/**
|
type PipOptionsMenuItemsProps = {
|
||||||
* PiP options menu items: excludes transcript, screen recording, and full screen
|
overflowControls?: Set<CollapsibleControl>
|
||||||
* (those features are not relevant in the PiP window context).
|
}
|
||||||
*/
|
|
||||||
export const PipOptionsMenuItems = () => (
|
export const PipOptionsMenuItems = ({
|
||||||
<RACMenu
|
overflowControls,
|
||||||
style={{
|
}: PipOptionsMenuItemsProps) => {
|
||||||
minWidth: '150px',
|
const { t } = useTranslation('rooms')
|
||||||
width: '300px',
|
const hasOverflow = overflowControls && overflowControls.size > 0
|
||||||
}}
|
|
||||||
>
|
return (
|
||||||
<MenuSection>
|
<RACMenu
|
||||||
<PictureInPictureMenuItem />
|
style={{
|
||||||
<EffectsMenuItem store={pipLayoutStore} />
|
minWidth: '150px',
|
||||||
</MenuSection>
|
width: '300px',
|
||||||
<Separator />
|
}}
|
||||||
<MenuSection>
|
>
|
||||||
<SupportMenuItem />
|
{hasOverflow && (
|
||||||
<FeedbackMenuItem />
|
<>
|
||||||
<SettingsMenuItem />
|
<MenuSection>
|
||||||
</MenuSection>
|
<OverflowItems overflowControls={overflowControls} t={t} />
|
||||||
</RACMenu>
|
</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 { RiMoreFill } from '@remixicon/react'
|
||||||
import { Button, Menu } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
import { OptionsMenuItems } from './OptionsMenuItems'
|
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 = () => {
|
export const OptionsButton = () => {
|
||||||
const { t } = useTranslation('rooms')
|
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 (
|
return (
|
||||||
<Menu variant="dark">
|
<Menu variant="dark">
|
||||||
|
|||||||
Reference in New Issue
Block a user