mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 12:09:08 +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 { 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 { 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 { PipOptionsMenu } from './controls/PipOptionsMenu'
|
import { PipOptionsMenu } from './controls/PipOptionsMenu'
|
||||||
|
import { PipReactionsToggle } from './PipReactionsToggle'
|
||||||
|
|
||||||
export type CollapsibleControl =
|
export type CollapsibleControl =
|
||||||
| 'hand'
|
| 'hand'
|
||||||
@@ -94,7 +94,7 @@ export const PipControlBar = ({
|
|||||||
<PipControlsCenter>
|
<PipControlsCenter>
|
||||||
<AudioDevicesControl hideMenu />
|
<AudioDevicesControl hideMenu />
|
||||||
<VideoDeviceControl hideMenu />
|
<VideoDeviceControl hideMenu />
|
||||||
{!hidden.has('reactions') && <ReactionsToggle />}
|
{!hidden.has('reactions') && <PipReactionsToggle />}
|
||||||
{showScreenShare && !hidden.has('screenShare') && <ScreenShareToggle />}
|
{showScreenShare && !hidden.has('screenShare') && <ScreenShareToggle />}
|
||||||
{!hidden.has('subtitles') && <SubtitlesToggle />}
|
{!hidden.has('subtitles') && <SubtitlesToggle />}
|
||||||
{!hidden.has('hand') && <HandToggle />}
|
{!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 { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
|
||||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||||
import { PipControlBar } from './PipControlBar'
|
import { PipControlBar } from './PipControlBar'
|
||||||
|
import { PipReactionsToolbar } from './PipReactionsToolbar'
|
||||||
|
|
||||||
const pickTrackForPip = (
|
const pickTrackForPip = (
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -72,7 +73,9 @@ export const PipView = () => {
|
|||||||
<PipStage>
|
<PipStage>
|
||||||
{hasMultipleTiles ? (
|
{hasMultipleTiles ? (
|
||||||
<>
|
<>
|
||||||
{mainTrackRef && <ParticipantTile trackRef={mainTrackRef} disableMetadata />}
|
{mainTrackRef && (
|
||||||
|
<ParticipantTile trackRef={mainTrackRef} disableMetadata />
|
||||||
|
)}
|
||||||
{thumbnailTrackRef && (
|
{thumbnailTrackRef && (
|
||||||
<PipThumbnail>
|
<PipThumbnail>
|
||||||
<ParticipantTile trackRef={thumbnailTrackRef} disableMetadata />
|
<ParticipantTile trackRef={thumbnailTrackRef} disableMetadata />
|
||||||
@@ -83,7 +86,7 @@ export const PipView = () => {
|
|||||||
<ParticipantTile trackRef={trackRef} disableMetadata />
|
<ParticipantTile trackRef={trackRef} disableMetadata />
|
||||||
)}
|
)}
|
||||||
</PipStage>
|
</PipStage>
|
||||||
{/* Compact control bar for PiP; extend here when adding more actions. */}
|
<PipReactionsToolbar />
|
||||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||||
{/* Side panel (effects, settings, etc.) opens within PiP window. */}
|
{/* Side panel (effects, settings, etc.) opens within PiP window. */}
|
||||||
<SidePanel store={pipLayoutStore} />
|
<SidePanel store={pipLayoutStore} />
|
||||||
@@ -96,7 +99,7 @@ const PipContainer = styled('div', {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'minmax(0, 1fr) auto',
|
gridTemplateRows: 'minmax(0, 1fr) auto auto',
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
'& .lk-participant-tile': {
|
'& .lk-participant-tile': {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -119,6 +122,9 @@ const PipStage = styled('div', {
|
|||||||
base: {
|
base: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
minHeight: 0,
|
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 { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||||
import { useAreSubtitlesAvailable } from '@/features/subtitle/hooks/useAreSubtitlesAvailable'
|
import { useAreSubtitlesAvailable } from '@/features/subtitle/hooks/useAreSubtitlesAvailable'
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
import { useSnapshot } from 'valtio'
|
||||||
import type { CollapsibleControl } from '../PipControlBar'
|
import type { CollapsibleControl } from '../PipControlBar'
|
||||||
|
|
||||||
type PipOptionsMenuItemsProps = {
|
type PipOptionsMenuItemsProps = {
|
||||||
@@ -74,7 +74,10 @@ const OverflowItems = ({
|
|||||||
})
|
})
|
||||||
const { areSubtitlesOpen, toggleSubtitles } = useSubtitles()
|
const { areSubtitlesOpen, toggleSubtitles } = useSubtitles()
|
||||||
const areSubtitlesAvailable = useAreSubtitlesAvailable()
|
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
|
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,9 +110,7 @@ const OverflowItems = ({
|
|||||||
{overflowControls.has('hand') && (
|
{overflowControls.has('hand') && (
|
||||||
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
|
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
|
||||||
<RiHand size={20} />
|
<RiHand size={20} />
|
||||||
{isHandRaised
|
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
|
||||||
? t('controls.hand.lower')
|
|
||||||
: t('controls.hand.raise')}
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
|||||||
type PipLayoutState = {
|
type PipLayoutState = {
|
||||||
activePanelId: PanelId | null
|
activePanelId: PanelId | null
|
||||||
activeSubPanelId: SubPanelId | null
|
activeSubPanelId: SubPanelId | null
|
||||||
|
showReactionsToolbar: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,4 +15,5 @@ type PipLayoutState = {
|
|||||||
export const pipLayoutStore = proxy<PipLayoutState>({
|
export const pipLayoutStore = proxy<PipLayoutState>({
|
||||||
activePanelId: null,
|
activePanelId: null,
|
||||||
activeSubPanelId: null,
|
activeSubPanelId: null,
|
||||||
|
showReactionsToolbar: false,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user