From 40da9c0bf90653821ae89189af967d2a9155f949 Mon Sep 17 00:00:00 2001 From: Cyril Date: Thu, 9 Apr 2026 13:26:00 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20PiP=20reactions=20t?= =?UTF-8?q?oolbar=20and=20isolated=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PiP-only reactions strip, store flag, and overflow menu wired to PiP state. --- .../features/pip/components/PipControlBar.tsx | 4 +- .../pip/components/PipReactionsToggle.tsx | 30 +++ .../pip/components/PipReactionsToolbar.tsx | 241 ++++++++++++++++++ .../src/features/pip/components/PipView.tsx | 12 +- .../controls/PipOptionsMenuItems.tsx | 11 +- .../src/features/pip/stores/pipLayoutStore.ts | 2 + 6 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 src/frontend/src/features/pip/components/PipReactionsToggle.tsx create mode 100644 src/frontend/src/features/pip/components/PipReactionsToolbar.tsx diff --git a/src/frontend/src/features/pip/components/PipControlBar.tsx b/src/frontend/src/features/pip/components/PipControlBar.tsx index 98306e82..23c9b067 100644 --- a/src/frontend/src/features/pip/components/PipControlBar.tsx +++ b/src/frontend/src/features/pip/components/PipControlBar.tsx @@ -7,8 +7,8 @@ import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveB import { SubtitlesToggle } from '@/features/rooms/livekit/components/controls/SubtitlesToggle' import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle' import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton' -import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle' import { PipOptionsMenu } from './controls/PipOptionsMenu' +import { PipReactionsToggle } from './PipReactionsToggle' export type CollapsibleControl = | 'hand' @@ -94,7 +94,7 @@ export const PipControlBar = ({ - {!hidden.has('reactions') && } + {!hidden.has('reactions') && } {showScreenShare && !hidden.has('screenShare') && } {!hidden.has('subtitles') && } {!hidden.has('hand') && } diff --git a/src/frontend/src/features/pip/components/PipReactionsToggle.tsx b/src/frontend/src/features/pip/components/PipReactionsToggle.tsx new file mode 100644 index 00000000..a788d87c --- /dev/null +++ b/src/frontend/src/features/pip/components/PipReactionsToggle.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next' +import { RiEmotionLine } from '@remixicon/react' +import { ToggleButton } from '@/primitives' +import { useSnapshot } from 'valtio' +import { pipLayoutStore } from '../stores/pipLayoutStore' + +export const PipReactionsToggle = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) + const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore) + + const toggle = () => { + pipLayoutStore.showReactionsToolbar = !pipLayoutStore.showReactionsToolbar + } + + return ( + + + + ) +} diff --git a/src/frontend/src/features/pip/components/PipReactionsToolbar.tsx b/src/frontend/src/features/pip/components/PipReactionsToolbar.tsx new file mode 100644 index 00000000..0ceddab6 --- /dev/null +++ b/src/frontend/src/features/pip/components/PipReactionsToolbar.tsx @@ -0,0 +1,241 @@ +import { useRef, useState, useEffect, useCallback, useMemo } from 'react' +import { styled } from '@/styled-system/jsx' +import { css } from '@/styled-system/css' +import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' +import { useSnapshot } from 'valtio' +import { pipLayoutStore } from '../stores/pipLayoutStore' +import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton' +import { Emoji } from '@/features/reactions/types' + +const EMOJIS = Object.values(Emoji) +const EMOJI_SLOT_WIDTH = 40 +const ARROW_SLOT_WIDTH = 32 +const PILL_HORIZONTAL_PADDING = 12 + +export const PipReactionsToolbar = () => { + const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore) + const wrapperRef = useRef(null) + const [isVisible, setIsVisible] = useState(false) + const [availableWidth, setAvailableWidth] = useState(0) + const [pageStart, setPageStart] = useState(0) + + useEffect(() => { + if (isOpen) { + const id = requestAnimationFrame(() => setIsVisible(true)) + return () => cancelAnimationFrame(id) + } else { + setIsVisible(false) + } + }, [isOpen]) + + const updateWidth = useCallback(() => { + const el = wrapperRef.current + if (!el) return + setAvailableWidth(el.getBoundingClientRect().width) + }, []) + + useEffect(() => { + const el = wrapperRef.current + if (!el) return + + updateWidth() + + const RO = + el.ownerDocument.defaultView?.ResizeObserver ?? window.ResizeObserver + const observer = new RO(() => updateWidth()) + observer.observe(el) + + return () => observer.disconnect() + }, [updateWidth]) + + const { visibleEmojis, hasOverflow, canGoLeft, canGoRight } = useMemo(() => { + const maxWithoutArrows = Math.max( + 1, + Math.floor((availableWidth - PILL_HORIZONTAL_PADDING) / EMOJI_SLOT_WIDTH) + ) + + if (EMOJIS.length <= maxWithoutArrows) { + return { + visibleEmojis: EMOJIS, + hasOverflow: false, + canGoLeft: false, + canGoRight: false, + } + } + + const visibleCount = Math.max( + 1, + Math.floor( + (availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) / + EMOJI_SLOT_WIDTH + ) + ) + const clampedStart = Math.min(pageStart, EMOJIS.length - visibleCount) + + return { + visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount), + hasOverflow: true, + canGoLeft: clampedStart > 0, + canGoRight: clampedStart + visibleCount < EMOJIS.length, + } + }, [pageStart, availableWidth]) + + useEffect(() => { + if (!hasOverflow) { + setPageStart(0) + return + } + const visibleCount = Math.max( + 1, + Math.floor( + (availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) / + EMOJI_SLOT_WIDTH + ) + ) + const maxStart = Math.max(0, EMOJIS.length - visibleCount) + if (pageStart > maxStart) { + setPageStart(maxStart) + } + }, [hasOverflow, pageStart, availableWidth]) + + const paginate = useCallback((direction: 'left' | 'right') => { + setPageStart((current) => + direction === 'left' ? Math.max(0, current - 1) : current + 1 + ) + }, []) + + return ( + + + {hasOverflow && ( + + {canGoLeft && ( + paginate('left')} + aria-label="Previous reactions" + > + + + )} + + )} + + {visibleEmojis.map((emoji) => ( + + ))} + + {hasOverflow && ( + + {canGoRight && ( + paginate('right')} + aria-label="Next reactions" + > + + + )} + + )} + + + ) +} + +const ToolbarWrapper = styled('div', { + base: { + display: 'flex', + justifyContent: 'center', + overflow: 'hidden', + maxHeight: 0, + padding: '0 0.5rem', + transition: + 'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1)', + }, + variants: { + isOpen: { + true: { + maxHeight: '60px', + padding: '0.5rem 0.5rem 0.25rem', + }, + }, + }, +}) + +const ToolbarPill = styled('div', { + base: { + display: 'flex', + alignItems: 'center', + gap: '0.2rem', + borderRadius: '21px', + padding: '0.15rem', + backgroundColor: 'primaryDark.100', + maxWidth: '100%', + overflow: 'hidden', + width: 'fit-content', + opacity: 0, + transform: 'translateY(3.25rem)', + transition: 'opacity, transform', + transitionDuration: '0.5s', + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + pointerEvents: 'none', + }, + variants: { + isVisible: { + true: { + opacity: 1, + transform: 'translateY(0)', + pointerEvents: 'auto', + }, + }, + }, +}) + +const EmojiRow = styled('div', { + base: { + display: 'flex', + gap: '0.2rem', + '& > *': { + flexShrink: 0, + }, + }, +}) + +const ArrowSlot = styled('div', { + base: { + width: '32px', + minWidth: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, +}) + +const ArrowButton = ({ + children, + ...props +}: React.ButtonHTMLAttributes) => ( + +) diff --git a/src/frontend/src/features/pip/components/PipView.tsx b/src/frontend/src/features/pip/components/PipView.tsx index 975ffb10..585c0a70 100644 --- a/src/frontend/src/features/pip/components/PipView.tsx +++ b/src/frontend/src/features/pip/components/PipView.tsx @@ -10,6 +10,7 @@ import { ParticipantTile } from '@/features/rooms/livekit/components/Participant import { SidePanel } from '@/features/rooms/livekit/components/SidePanel' import { pipLayoutStore } from '../stores/pipLayoutStore' import { PipControlBar } from './PipControlBar' +import { PipReactionsToolbar } from './PipReactionsToolbar' const pickTrackForPip = ( tracks: TrackReferenceOrPlaceholder[] @@ -72,7 +73,9 @@ export const PipView = () => { {hasMultipleTiles ? ( <> - {mainTrackRef && } + {mainTrackRef && ( + + )} {thumbnailTrackRef && ( @@ -83,7 +86,7 @@ export const PipView = () => { )} - {/* Compact control bar for PiP; extend here when adding more actions. */} + {/* Side panel (effects, settings, etc.) opens within PiP window. */} @@ -96,7 +99,7 @@ const PipContainer = styled('div', { width: '100%', height: '100%', display: 'grid', - gridTemplateRows: 'minmax(0, 1fr) auto', + gridTemplateRows: 'minmax(0, 1fr) auto auto', backgroundColor: 'primaryDark.50', '& .lk-participant-tile': { height: '100%', @@ -119,6 +122,9 @@ const PipStage = styled('div', { base: { position: 'relative', minHeight: 0, + margin: '0.5rem', + borderRadius: 'lg', + overflow: 'hidden', }, }) diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx index a63598be..627c6db5 100644 --- a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx @@ -18,7 +18,7 @@ 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 { useSnapshot } from 'valtio' import type { CollapsibleControl } from '../PipControlBar' type PipOptionsMenuItemsProps = { @@ -74,7 +74,10 @@ const OverflowItems = ({ }) const { areSubtitlesOpen, toggleSubtitles } = useSubtitles() const areSubtitlesAvailable = useAreSubtitlesAvailable() - const { toggle: toggleReactions } = useReactionsToolbar() + const pipSnap = useSnapshot(pipLayoutStore) + const toggleReactions = () => { + pipLayoutStore.showReactionsToolbar = !pipSnap.showReactionsToolbar + } const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item return ( @@ -107,9 +110,7 @@ const OverflowItems = ({ {overflowControls.has('hand') && ( - {isHandRaised - ? t('controls.hand.lower') - : t('controls.hand.raise')} + {isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')} )} diff --git a/src/frontend/src/features/pip/stores/pipLayoutStore.ts b/src/frontend/src/features/pip/stores/pipLayoutStore.ts index e67253a6..bc3db7e8 100644 --- a/src/frontend/src/features/pip/stores/pipLayoutStore.ts +++ b/src/frontend/src/features/pip/stores/pipLayoutStore.ts @@ -4,6 +4,7 @@ import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel' type PipLayoutState = { activePanelId: PanelId | null activeSubPanelId: SubPanelId | null + showReactionsToolbar: boolean } /** @@ -14,4 +15,5 @@ type PipLayoutState = { export const pipLayoutStore = proxy({ activePanelId: null, activeSubPanelId: null, + showReactionsToolbar: false, })