mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
✨(frontend) add PiP reactions toolbar and isolated state
PiP-only reactions strip, store flag, and overflow menu wired to PiP state.
This commit is contained in:
@@ -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 = ({
|
||||
<PipControlsCenter>
|
||||
<AudioDevicesControl hideMenu />
|
||||
<VideoDeviceControl hideMenu />
|
||||
{!hidden.has('reactions') && <ReactionsToggle />}
|
||||
{!hidden.has('reactions') && <PipReactionsToggle />}
|
||||
{showScreenShare && !hidden.has('screenShare') && <ScreenShareToggle />}
|
||||
{!hidden.has('subtitles') && <SubtitlesToggle />}
|
||||
{!hidden.has('hand') && <HandToggle />}
|
||||
|
||||
@@ -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 (
|
||||
<ToggleButton
|
||||
id="pip-reactions-toggle"
|
||||
data-attr="pip-reactions-toggle"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
aria-expanded={isOpen}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={toggle}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<ToolbarWrapper ref={wrapperRef} isOpen={isOpen}>
|
||||
<ToolbarPill isVisible={isVisible}>
|
||||
{hasOverflow && (
|
||||
<ArrowSlot>
|
||||
{canGoLeft && (
|
||||
<ArrowButton
|
||||
onClick={() => paginate('left')}
|
||||
aria-label="Previous reactions"
|
||||
>
|
||||
<RiArrowLeftSLine size={16} />
|
||||
</ArrowButton>
|
||||
)}
|
||||
</ArrowSlot>
|
||||
)}
|
||||
<EmojiRow>
|
||||
{visibleEmojis.map((emoji) => (
|
||||
<ReactionButton key={emoji} emoji={emoji} />
|
||||
))}
|
||||
</EmojiRow>
|
||||
{hasOverflow && (
|
||||
<ArrowSlot>
|
||||
{canGoRight && (
|
||||
<ArrowButton
|
||||
onClick={() => paginate('right')}
|
||||
aria-label="Next reactions"
|
||||
>
|
||||
<RiArrowRightSLine size={16} />
|
||||
</ArrowButton>
|
||||
)}
|
||||
</ArrowSlot>
|
||||
)}
|
||||
</ToolbarPill>
|
||||
</ToolbarWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>) => (
|
||||
<button
|
||||
type="button"
|
||||
className={css({
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.85,
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
backgroundColor: 'primaryDark.300',
|
||||
},
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
@@ -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 = () => {
|
||||
<PipStage>
|
||||
{hasMultipleTiles ? (
|
||||
<>
|
||||
{mainTrackRef && <ParticipantTile trackRef={mainTrackRef} disableMetadata />}
|
||||
{mainTrackRef && (
|
||||
<ParticipantTile trackRef={mainTrackRef} disableMetadata />
|
||||
)}
|
||||
{thumbnailTrackRef && (
|
||||
<PipThumbnail>
|
||||
<ParticipantTile trackRef={thumbnailTrackRef} disableMetadata />
|
||||
@@ -83,7 +86,7 @@ export const PipView = () => {
|
||||
<ParticipantTile trackRef={trackRef} disableMetadata />
|
||||
)}
|
||||
</PipStage>
|
||||
{/* Compact control bar for PiP; extend here when adding more actions. */}
|
||||
<PipReactionsToolbar />
|
||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||
{/* Side panel (effects, settings, etc.) opens within PiP window. */}
|
||||
<SidePanel store={pipLayoutStore} />
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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') && (
|
||||
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
|
||||
<RiHand size={20} />
|
||||
{isHandRaised
|
||||
? t('controls.hand.lower')
|
||||
: t('controls.hand.raise')}
|
||||
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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<PipLayoutState>({
|
||||
activePanelId: null,
|
||||
activeSubPanelId: null,
|
||||
showReactionsToolbar: false,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user