From 1e212742372ace8ed6c014afa3dae45b9a99990b Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 8 Apr 2026 10:59:13 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20fix=20pip=20controls?= =?UTF-8?q?=20responsive=20overflow=20in=20realtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use PiP document ResizeObserver so controls hide into "..." while resizing. --- .../features/pip/components/PipControlBar.tsx | 111 ++++++++++++--- .../components/controls/PipOptionsMenu.tsx | 30 ++-- .../controls/PipOptionsMenuItems.tsx | 133 ++++++++++++++---- .../controls/Options/OptionsButton.tsx | 18 --- 4 files changed, 214 insertions(+), 78 deletions(-) diff --git a/src/frontend/src/features/pip/components/PipControlBar.tsx b/src/frontend/src/features/pip/components/PipControlBar.tsx index f63d5ff3..98306e82 100644 --- a/src/frontend/src/features/pip/components/PipControlBar.tsx +++ b/src/frontend/src/features/pip/components/PipControlBar.tsx @@ -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 { + const hidden = new Set() + 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) { + 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 -}) => ( - - - - - - {showScreenShare && } - - - - - - - -) +}) => { + const containerRef = useRef(null) + const width = usePipSize(containerRef) + + const hidden = useMemo( + () => getHiddenControls(width, showScreenShare), + [width, showScreenShare] + ) + + return ( + + + + + {!hidden.has('reactions') && } + {showScreenShare && !hidden.has('screenShare') && } + {!hidden.has('subtitles') && } + {!hidden.has('hand') && } + + + + + + ) +} 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', diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx index 93c350e1..9d487f31 100644 --- a/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx @@ -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 - isOpen: boolean - setIsOpen: (isOpen: boolean) => void - label: string + overflowControls?: Set } /** * 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(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 (
- +
)} diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx index e8ad1b74..a63598be 100644 --- a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx @@ -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 = () => ( - - - - - - - - - - - - -) +type PipOptionsMenuItemsProps = { + overflowControls?: Set +} + +export const PipOptionsMenuItems = ({ + overflowControls, +}: PipOptionsMenuItemsProps) => { + const { t } = useTranslation('rooms') + const hasOverflow = overflowControls && overflowControls.size > 0 + + return ( + + {hasOverflow && ( + <> + + + + + + )} + + + + + + + + + + + + ) +} + +const OverflowItems = ({ + overflowControls, + t, +}: { + overflowControls: Set + // 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') && ( + + + {t('controls.reactions.button')} + + )} + {overflowControls.has('screenShare') && ( + { + /* screen share requires track toggle, handled externally */ + }} + className={itemClass} + > + + {t('controls.screenShare.start')} + + )} + {overflowControls.has('subtitles') && areSubtitlesAvailable && ( + + + {areSubtitlesOpen + ? t('controls.subtitles.open') + : t('controls.subtitles.closed')} + + )} + {overflowControls.has('hand') && ( + + + {isHandRaised + ? t('controls.hand.lower') + : t('controls.hand.raise')} + + )} + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsButton.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsButton.tsx index 2f3fbad2..715dcd30 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsButton.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsButton.tsx @@ -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(null) - - if (isInPiP) { - return ( - - ) - } return (