mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
44 Commits
thunderbird
...
test-pip
| Author | SHA1 | Date | |
|---|---|---|---|
| 135ce78609 | |||
| 7f23cf1735 | |||
| 1936c692b2 | |||
| 435990f589 | |||
| 1e872e4c3c | |||
| 7e9b3a6290 | |||
| 1080407bb3 | |||
| d7fb31ef2c | |||
| c8ae87b082 | |||
| 636d298c18 | |||
| f0c5a8c241 | |||
| 946da58fb2 | |||
| 96edb3725a | |||
| 204d40e0ca | |||
| c16f4ca846 | |||
| 5362f37883 | |||
| bac7261fff | |||
| 40da9c0bf9 | |||
| 284c1ccc8a | |||
| 69c9559843 | |||
| 07ed4d701c | |||
| a40c76bc4d | |||
| e9684e0c01 | |||
| 1c5b6b1e1f | |||
| e38d0861b1 | |||
| 0ab2e2f18f | |||
| 99dcd34482 | |||
| 2bdd8ddbaf | |||
| 94488f31cf | |||
| e49355985a | |||
| 8e3cd3f9a3 | |||
| 742c1b467b | |||
| 37b5dba12f | |||
| 6912d4115d | |||
| d1a3052f28 | |||
| fcfcb3eff3 | |||
| 5863b4495a | |||
| cf37d636db | |||
| 3fd265f291 | |||
| 2c30535235 | |||
| cea373a00f | |||
| 857dccd00b | |||
| f0f6c4bf56 | |||
| 85e13f7e44 |
@@ -180,6 +180,8 @@ and this project adheres to
|
|||||||
|
|
||||||
- ✨(backend) monitor throttling rate failure through sentry #964
|
- ✨(backend) monitor throttling rate failure through sentry #964
|
||||||
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
|
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
|
||||||
|
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
|
declare module '@react-aria/overlays' {
|
||||||
|
export type PortalProviderContextValue = {
|
||||||
|
getContainer: () => HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalProviderProps = {
|
||||||
|
getContainer: () => HTMLElement | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUNSAFE_PortalContext(): PortalProviderContextValue;
|
||||||
|
export function UNSAFE_PortalProvider(
|
||||||
|
props: PortalProviderProps,
|
||||||
|
): JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||||
@@ -16,6 +16,10 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|||||||
import { Emoji } from '@/features/reactions/types'
|
import { Emoji } from '@/features/reactions/types'
|
||||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||||
|
|
||||||
|
// Sliding window of recent chat ids kept for deduplication. Sized to comfortably
|
||||||
|
// cover bursts and re-emits while staying negligible in memory.
|
||||||
|
const MAX_TRACKED_CHAT_IDS = 16
|
||||||
|
|
||||||
export const MainNotificationToast = () => {
|
export const MainNotificationToast = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { triggerNotificationSound } = useNotificationSound()
|
const { triggerNotificationSound } = useNotificationSound()
|
||||||
@@ -24,12 +28,23 @@ export const MainNotificationToast = () => {
|
|||||||
|
|
||||||
const { appendReaction } = useReactions()
|
const { appendReaction } = useReactions()
|
||||||
|
|
||||||
|
// Multiple Chat instances may re-emit the same RoomEvent.ChatMessage.
|
||||||
|
// Dedupe against a small ring of recent ids.
|
||||||
|
const seenChatMsgIdsRef = useRef<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChatMessage = (
|
const handleChatMessage = (
|
||||||
chatMessage: ChatMessage,
|
chatMessage: ChatMessage,
|
||||||
participant?: Participant | undefined
|
participant?: Participant | undefined
|
||||||
) => {
|
) => {
|
||||||
if (!participant || participant.isLocal) return
|
if (!participant || participant.isLocal) return
|
||||||
|
const id = chatMessage.id
|
||||||
|
if (id) {
|
||||||
|
const seen = seenChatMsgIdsRef.current
|
||||||
|
if (seen.includes(id)) return
|
||||||
|
seen.push(id)
|
||||||
|
if (seen.length > MAX_TRACKED_CHAT_IDS) seen.shift()
|
||||||
|
}
|
||||||
triggerNotificationSound(NotificationType.MessageReceived)
|
triggerNotificationSound(NotificationType.MessageReceived)
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useDocumentPiP } from '../hooks/useDocumentPiP'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
|
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
|
||||||
|
|
||||||
|
// Minimal base styles so the PiP window renders correctly on first paint.
|
||||||
|
const ensureBaseStyles = (target: Document) => {
|
||||||
|
if (target.getElementById('pip-base-styles')) return
|
||||||
|
const style = target.createElement('style')
|
||||||
|
style.id = 'pip-base-styles'
|
||||||
|
style.textContent = `
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; background: #0b0f19; }
|
||||||
|
body { overflow: hidden; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
`
|
||||||
|
target.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone existing styles to keep the PiP window visually consistent.
|
||||||
|
const copyStyles = (source: Document, target: Document) => {
|
||||||
|
if (target.getElementById('pip-style-clone')) return
|
||||||
|
const marker = target.createElement('meta')
|
||||||
|
marker.id = 'pip-style-clone'
|
||||||
|
target.head.appendChild(marker)
|
||||||
|
|
||||||
|
source.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
|
||||||
|
const cloned = node.cloneNode(true) as HTMLElement
|
||||||
|
target.head.appendChild(cloned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncThemeAttribute = (source: Document, target: Document) => {
|
||||||
|
const theme = source.documentElement.getAttribute('data-lk-theme')
|
||||||
|
if (theme) {
|
||||||
|
target.documentElement.setAttribute('data-lk-theme', theme)
|
||||||
|
} else {
|
||||||
|
target.documentElement.removeAttribute('data-lk-theme')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssVarNameCacheByElement = new WeakMap<HTMLElement, string[]>()
|
||||||
|
const cssVarNameCacheByUri = new Map<string, string[]>()
|
||||||
|
|
||||||
|
const syncCssVariables = (source: Document, target: Document) => {
|
||||||
|
const sourceView = source.defaultView
|
||||||
|
if (!sourceView) return
|
||||||
|
|
||||||
|
const getCachedVarNames = () => {
|
||||||
|
const docEl = source.documentElement
|
||||||
|
if (!docEl) return []
|
||||||
|
|
||||||
|
const cachedByElement = cssVarNameCacheByElement.get(docEl)
|
||||||
|
if (cachedByElement) return cachedByElement
|
||||||
|
|
||||||
|
const cachedByUri = source.baseURI
|
||||||
|
? cssVarNameCacheByUri.get(source.baseURI)
|
||||||
|
: undefined
|
||||||
|
if (cachedByUri) return cachedByUri
|
||||||
|
|
||||||
|
const varNames = new Set<string>()
|
||||||
|
const collectVarsFrom = (element: HTMLElement | null) => {
|
||||||
|
if (!element) return
|
||||||
|
const styles = sourceView.getComputedStyle(element)
|
||||||
|
for (let i = 0; i < styles.length; i += 1) {
|
||||||
|
const property = styles[i]
|
||||||
|
if (property.startsWith('--')) {
|
||||||
|
varNames.add(property)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectVarsFrom(source.documentElement)
|
||||||
|
collectVarsFrom(source.body)
|
||||||
|
|
||||||
|
const result = Array.from(varNames)
|
||||||
|
cssVarNameCacheByElement.set(docEl, result)
|
||||||
|
if (source.baseURI) {
|
||||||
|
cssVarNameCacheByUri.set(source.baseURI, result)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const varNames = getCachedVarNames()
|
||||||
|
if (!varNames.length) return
|
||||||
|
|
||||||
|
const rootStyles = sourceView.getComputedStyle(source.documentElement)
|
||||||
|
const bodyStyles = source.body
|
||||||
|
? sourceView.getComputedStyle(source.body)
|
||||||
|
: null
|
||||||
|
|
||||||
|
varNames.forEach((property) => {
|
||||||
|
const bodyValue = bodyStyles?.getPropertyValue(property)
|
||||||
|
const value = bodyValue || rootStyles.getPropertyValue(property)
|
||||||
|
if (value) {
|
||||||
|
target.documentElement.style.setProperty(property, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React portal into a Document Picture-in-Picture window. Handles window
|
||||||
|
* lifecycle, style/theme sync and routes React Aria overlays via
|
||||||
|
* `UNSAFE_PortalProvider` so they render inside the PiP document.
|
||||||
|
*/
|
||||||
|
export const DocumentPiPPortal = ({
|
||||||
|
isOpen,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
children: React.ReactNode
|
||||||
|
onClose?: () => void
|
||||||
|
}): ReactNode => {
|
||||||
|
const { openPiP, closePiP, pipWindow, isSupported } = useDocumentPiP({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||||
|
const containerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const prevOpenRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
closePiP()
|
||||||
|
setContainer(null)
|
||||||
|
containerRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupported) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
openPiP().then((win) => {
|
||||||
|
if (!win || cancelled) return
|
||||||
|
const doc = win.document
|
||||||
|
ensureBaseStyles(doc)
|
||||||
|
copyStyles(document, doc)
|
||||||
|
syncThemeAttribute(document, doc)
|
||||||
|
syncCssVariables(document, doc)
|
||||||
|
|
||||||
|
doc.documentElement.setAttribute('lang', document.documentElement.lang)
|
||||||
|
doc.title = t('windowLabel')
|
||||||
|
|
||||||
|
const existingContainer = containerRef.current
|
||||||
|
if (!existingContainer || existingContainer.ownerDocument !== doc) {
|
||||||
|
const nextContainer = doc.createElement('div')
|
||||||
|
nextContainer.id = 'pip-root'
|
||||||
|
nextContainer.style.width = '100%'
|
||||||
|
nextContainer.style.height = '100%'
|
||||||
|
nextContainer.style.display = 'flex'
|
||||||
|
nextContainer.style.alignItems = 'stretch'
|
||||||
|
nextContainer.style.justifyContent = 'center'
|
||||||
|
doc.body.appendChild(nextContainer)
|
||||||
|
containerRef.current = nextContainer
|
||||||
|
setContainer(nextContainer)
|
||||||
|
} else {
|
||||||
|
setContainer(existingContainer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [closePiP, isOpen, isSupported, openPiP, t])
|
||||||
|
|
||||||
|
// Focus stays on the trigger; PiP is announced as an auxiliary surface.
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = prevOpenRef.current
|
||||||
|
prevOpenRef.current = isOpen
|
||||||
|
|
||||||
|
if (isOpen && !wasOpen) {
|
||||||
|
announce(t('opened'), 'polite')
|
||||||
|
}
|
||||||
|
if (!isOpen && wasOpen) {
|
||||||
|
announce(t('closed'), 'polite')
|
||||||
|
}
|
||||||
|
}, [isOpen, announce, t])
|
||||||
|
|
||||||
|
useRestoreFocus(isOpen, { restoreFocusRaf: true })
|
||||||
|
|
||||||
|
// Escape from either document closes PiP (unless a nested overlay handled it).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||||
|
event.preventDefault()
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
pipWindow?.document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
pipWindow?.document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose, pipWindow])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pipWindow) return
|
||||||
|
const handleClose = () => {
|
||||||
|
containerRef.current = null
|
||||||
|
setContainer(null)
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
pipWindow.addEventListener('pagehide', handleClose)
|
||||||
|
pipWindow.addEventListener('beforeunload', handleClose)
|
||||||
|
return () => {
|
||||||
|
pipWindow.removeEventListener('pagehide', handleClose)
|
||||||
|
pipWindow.removeEventListener('beforeunload', handleClose)
|
||||||
|
}
|
||||||
|
}, [onClose, pipWindow])
|
||||||
|
|
||||||
|
const portal = useMemo(() => {
|
||||||
|
if (!container) return null
|
||||||
|
return createPortal(
|
||||||
|
<UNSAFE_PortalProvider getContainer={() => container}>
|
||||||
|
{children}
|
||||||
|
</UNSAFE_PortalProvider>,
|
||||||
|
container
|
||||||
|
)
|
||||||
|
}, [children, container])
|
||||||
|
|
||||||
|
return portal as unknown as ReactNode
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { useRef, useMemo, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { findFirstFocusable } from '@/utils/dom'
|
||||||
|
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 { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
|
||||||
|
import { usePipElementSize } from '../hooks/usePipElementSize'
|
||||||
|
import { PipOptionsMenu } from './controls/PipOptionsMenu'
|
||||||
|
import { PipReactionsToggle } from './PipReactionsToggle'
|
||||||
|
|
||||||
|
export type CollapsibleControl =
|
||||||
|
| 'hand'
|
||||||
|
// | 'subtitles'
|
||||||
|
| 'screenShare'
|
||||||
|
| 'reactions'
|
||||||
|
|
||||||
|
const COLLAPSE_ORDER: CollapsibleControl[] = [
|
||||||
|
'hand',
|
||||||
|
// 'subtitles',
|
||||||
|
'screenShare',
|
||||||
|
'reactions',
|
||||||
|
]
|
||||||
|
|
||||||
|
const BUTTON_SLOT = 50
|
||||||
|
const ESSENTIAL_WIDTH = 260
|
||||||
|
|
||||||
|
const 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PipControlBar = ({
|
||||||
|
showScreenShare,
|
||||||
|
}: {
|
||||||
|
showScreenShare: boolean
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { width } = usePipElementSize(containerRef)
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
|
||||||
|
const hidden = useMemo(
|
||||||
|
() => getHiddenControls(width, showScreenShare),
|
||||||
|
[width, showScreenShare]
|
||||||
|
)
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'focus-toolbar',
|
||||||
|
handler: useCallback(() => {
|
||||||
|
const doc = containerRef.current?.ownerDocument ?? document
|
||||||
|
findFirstFocusable(doc.getElementById('pip-control-bar'))?.focus()
|
||||||
|
}, []),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PipControls
|
||||||
|
ref={containerRef}
|
||||||
|
id="pip-control-bar"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('controlBar')}
|
||||||
|
>
|
||||||
|
<PipControlsCenter>
|
||||||
|
<AudioDevicesControl hideMenu />
|
||||||
|
<VideoDeviceControl hideMenu />
|
||||||
|
{!hidden.has('reactions') && <PipReactionsToggle />}
|
||||||
|
{showScreenShare && !hidden.has('screenShare') && <ScreenShareToggle />}
|
||||||
|
{/*{!hidden.has('subtitles') && <SubtitlesToggle />}*/}
|
||||||
|
{!hidden.has('hand') && <HandToggle />}
|
||||||
|
<PipOptionsMenu overflowControls={hidden} />
|
||||||
|
<LeaveButton />
|
||||||
|
<StartMediaButton />
|
||||||
|
</PipControlsCenter>
|
||||||
|
</PipControls>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PipControls = styled('div', {
|
||||||
|
base: {
|
||||||
|
flex: '0 0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PipControlsCenter = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
|
import { ToggleButton } from '@/primitives'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
|
||||||
|
export const PipReactionsToggle = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
pipLayoutStore.showReactionsToolbar = !pipLayoutStore.showReactionsToolbar
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({ id: 'reaction', handler: toggle })
|
||||||
|
|
||||||
|
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,69 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||||
|
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||||
|
import { usePipElementSize } from '../hooks/usePipElementSize'
|
||||||
|
import { PipReactionsKeyboardNavigation } from './reactions/PipReactionsKeyboardNavigation'
|
||||||
|
import { PipReactionsPill } from './reactions/PipReactionsPill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactions toolbar for the PiP window. Owns only the open/close orchestration;
|
||||||
|
* layout and pagination live in `PipReactionsPill`, keyboard nav in
|
||||||
|
* `PipReactionsKeyboardNavigation`.
|
||||||
|
*/
|
||||||
|
export const PipReactionsToolbar = () => {
|
||||||
|
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
|
||||||
|
// Unmount content after the close transition so hidden emojis leave the tab order.
|
||||||
|
const renderContent = useDelayUnmount(isOpen, 500)
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { width: availableWidth } = usePipElementSize(wrapperRef)
|
||||||
|
|
||||||
|
// Mark the subtree inert during the fade-out so Tab can't land on it.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = contentRef.current
|
||||||
|
if (!el) return
|
||||||
|
if (isOpen) el.removeAttribute('inert')
|
||||||
|
else el.setAttribute('inert', '')
|
||||||
|
}, [isOpen, renderContent])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper ref={wrapperRef} isOpen={isOpen}>
|
||||||
|
{renderContent && (
|
||||||
|
<div ref={contentRef}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
|
<FocusScope autoFocus>
|
||||||
|
<PipReactionsKeyboardNavigation>
|
||||||
|
<PipReactionsPill
|
||||||
|
isOpen={isOpen}
|
||||||
|
availableWidth={availableWidth}
|
||||||
|
/>
|
||||||
|
</PipReactionsKeyboardNavigation>
|
||||||
|
</FocusScope>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Wrapper = 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import { supportsScreenSharing } from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||||
|
import { useEscapeDismiss } from '../hooks/useEscapeDismiss'
|
||||||
|
import { usePipKeyboardShortcuts } from '../hooks/usePipKeyboardShortcuts'
|
||||||
|
import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
|
||||||
|
import { PipControlBar } from './PipControlBar'
|
||||||
|
import { PipReactionsToolbar } from './PipReactionsToolbar'
|
||||||
|
import { PipStage } from './layouts/PipStage'
|
||||||
|
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
|
||||||
|
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
|
||||||
|
|
||||||
|
export const PipView = () => {
|
||||||
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { isSidePanelOpen, closePanel } = useSidePanel(pipLayoutStore)
|
||||||
|
|
||||||
|
// Escape closes the side panel instead of the whole PiP window.
|
||||||
|
useEscapeDismiss(containerRef, isSidePanelOpen, closePanel)
|
||||||
|
|
||||||
|
// Forward keyboard shortcuts (Ctrl+D, Ctrl+E, etc.) to the main store.
|
||||||
|
usePipKeyboardShortcuts(containerRef)
|
||||||
|
|
||||||
|
// Side panels open via a menu item that unmounts on click; fall back to the
|
||||||
|
// options button so focus returns somewhere visible.
|
||||||
|
const resolveTrigger = useCallback((activeEl: HTMLElement | null) => {
|
||||||
|
if (activeEl?.tagName === 'DIV') {
|
||||||
|
const doc = containerRef.current?.ownerDocument ?? document
|
||||||
|
return doc.getElementById('room-options-trigger')
|
||||||
|
}
|
||||||
|
return activeEl
|
||||||
|
}, [])
|
||||||
|
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PipContainer
|
||||||
|
ref={containerRef}
|
||||||
|
role="region"
|
||||||
|
aria-label={t('windowLabel')}
|
||||||
|
>
|
||||||
|
<PipStage />
|
||||||
|
<PipReactionsToolbar />
|
||||||
|
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||||
|
<SidePanel store={pipLayoutStore} />
|
||||||
|
<OverlayStack>
|
||||||
|
<PipConnectionStateToast />
|
||||||
|
<PipNotificationOverlay />
|
||||||
|
</OverlayStack>
|
||||||
|
</PipContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const OverlayStack = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.5rem',
|
||||||
|
left: '0.5rem',
|
||||||
|
right: '0.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.375rem',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1000,
|
||||||
|
'& > *': { pointerEvents: 'auto' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PipContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'minmax(0, 1fr) auto auto',
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
// Disable LiveKit's own border-radius on tiles so our containers
|
||||||
|
// (GridCell, Thumbnail, StageFrame) own the clipping exclusively.
|
||||||
|
'--lk-border-radius': '4px',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'& .lk-participant-media': {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'& .lk-participant-media-video': {
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
},
|
||||||
|
'& .lk-grid-layout': {
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { roomPiPStore } from '@/stores/roomPiP'
|
||||||
|
import { DocumentPiPPortal } from './DocumentPiPPortal'
|
||||||
|
import { PipView } from './PipView'
|
||||||
|
import { useRoomPiP } from '../hooks/useRoomPiP'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper that mounts the PiP UI when room-level PiP state is enabled.
|
||||||
|
* Bridges Valtio-backed PiP state with DocumentPiPPortal and PipView rendering.
|
||||||
|
* PiP panel state is decoupled via explicit pipLayoutStore injection.
|
||||||
|
*/
|
||||||
|
export const RoomPiP = (): ReactNode => {
|
||||||
|
const { isOpen, close } = useRoomPiP()
|
||||||
|
|
||||||
|
// Reset PiP state on unmount (e.g. leaving the room) so the next session
|
||||||
|
// starts with PiP closed and doesn't try to auto-reopen without a user gesture.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
roomPiPStore.isOpen = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const portal = DocumentPiPPortal({
|
||||||
|
isOpen,
|
||||||
|
onClose: close,
|
||||||
|
children: <PipView />,
|
||||||
|
})
|
||||||
|
return portal as ReactNode
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { RiMoreFill } from '@remixicon/react'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
|
import { Box, Button } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
|
||||||
|
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
|
||||||
|
import type { CollapsibleControl } from '../PipControlBar'
|
||||||
|
|
||||||
|
type PipOptionsMenuProps = {
|
||||||
|
overflowControls?: Set<CollapsibleControl>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PiP-native options menu. The shared `Menu` primitive mis-positions its
|
||||||
|
* popover and loses focus across documents, so we drive open/close, focus
|
||||||
|
* and dismissal ourselves.
|
||||||
|
*/
|
||||||
|
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const label = t('options.buttonLabel')
|
||||||
|
|
||||||
|
useEscapeDismiss(wrapperRef, isOpen, () => {
|
||||||
|
setIsOpen(false)
|
||||||
|
requestAnimationFrame(() => triggerRef.current?.focus())
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const doc = wrapperRef.current?.ownerDocument ?? document
|
||||||
|
|
||||||
|
const handleMenuItemClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper || !target) return
|
||||||
|
if (wrapper.querySelector('button')?.contains(target)) return
|
||||||
|
if (target.closest('[role="menuitem"]')) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsOpen(false)
|
||||||
|
triggerRef.current?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutsideClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper || !target) return
|
||||||
|
if (wrapper.contains(target)) return
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.addEventListener('click', handleMenuItemClick, true)
|
||||||
|
doc.addEventListener('mousedown', handleOutsideClick, true)
|
||||||
|
return () => {
|
||||||
|
doc.removeEventListener('click', handleMenuItemClick, true)
|
||||||
|
doc.removeEventListener('mousedown', handleOutsideClick, true)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={css({
|
||||||
|
position: 'relative',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
id="room-options-trigger"
|
||||||
|
square
|
||||||
|
variant="primaryDark"
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
tooltip={label}
|
||||||
|
onPress={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<RiMoreFill />
|
||||||
|
</Button>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
bottom: 'calc(100% + 0.85rem)',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
|
<FocusScope autoFocus>
|
||||||
|
<Box size="sm" type="popover" variant="dark">
|
||||||
|
<PipOptionsMenuItems overflowControls={overflowControls} />
|
||||||
|
</Box>
|
||||||
|
</FocusScope>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Separator } from '@/primitives/Separator'
|
||||||
|
import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem'
|
||||||
|
import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
|
||||||
|
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 { PipOverflowItems } from './PipOverflowItems'
|
||||||
|
import type { CollapsibleControl } from '../PipControlBar'
|
||||||
|
|
||||||
|
type PipOptionsMenuItemsProps = {
|
||||||
|
overflowControls?: Set<CollapsibleControl>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PipOptionsMenuItems = ({
|
||||||
|
overflowControls,
|
||||||
|
}: PipOptionsMenuItemsProps) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const hasOverflow = overflowControls && overflowControls.size > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RACMenu
|
||||||
|
style={{
|
||||||
|
minWidth: '150px',
|
||||||
|
width: '300px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasOverflow && (
|
||||||
|
<>
|
||||||
|
<MenuSection>
|
||||||
|
<PipOverflowItems overflowControls={overflowControls} t={t} />
|
||||||
|
</MenuSection>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<MenuSection>
|
||||||
|
<PictureInPictureMenuItem />
|
||||||
|
<EffectsMenuItem store={pipLayoutStore} />
|
||||||
|
</MenuSection>
|
||||||
|
<Separator />
|
||||||
|
{/*<MenuSection>*/}
|
||||||
|
{/* <SupportMenuItem />*/}
|
||||||
|
{/* <FeedbackMenuItem />*/}
|
||||||
|
{/*</MenuSection>*/}
|
||||||
|
</RACMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import {
|
||||||
|
RiHand,
|
||||||
|
RiClosedCaptioningLine,
|
||||||
|
RiArrowUpLine,
|
||||||
|
RiEmotionLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { TFunction } from 'i18next'
|
||||||
|
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 { useSnapshot } from 'valtio'
|
||||||
|
import type { CollapsibleControl } from '../PipControlBar'
|
||||||
|
|
||||||
|
type PipOverflowItemsProps = {
|
||||||
|
overflowControls: Set<CollapsibleControl>
|
||||||
|
t: TFunction<'rooms'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PipOverflowItems = ({
|
||||||
|
overflowControls,
|
||||||
|
t,
|
||||||
|
}: PipOverflowItemsProps) => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||||
|
participant: room.localParticipant,
|
||||||
|
})
|
||||||
|
const { areSubtitlesOpen, toggleSubtitles } = useSubtitles()
|
||||||
|
const areSubtitlesAvailable = useAreSubtitlesAvailable()
|
||||||
|
const pipSnap = useSnapshot(pipLayoutStore)
|
||||||
|
const toggleReactions = () => {
|
||||||
|
pipLayoutStore.showReactionsToolbar = !pipSnap.showReactionsToolbar
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
|
type PipFocusLayoutProps = {
|
||||||
|
mainTrack: TrackReferenceOrPlaceholder
|
||||||
|
thumbnailTrack?: TrackReferenceOrPlaceholder
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus layout used when 1-2 tracks are visible in the PiP window.
|
||||||
|
*
|
||||||
|
* The main tile is letterboxed (object-fit: contain) so the camera is
|
||||||
|
* never stretched to a non-video aspect and leaves dark padding
|
||||||
|
* above/below when the window shape doesn't match the source.
|
||||||
|
* The thumbnail keeps the usual cover fill.
|
||||||
|
*/
|
||||||
|
export const PipFocusLayout = memo(
|
||||||
|
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<FocusContainer>
|
||||||
|
<MainSlot>
|
||||||
|
<ParticipantTile
|
||||||
|
key={getTrackKey(mainTrack)}
|
||||||
|
trackRef={mainTrack}
|
||||||
|
disableMetadata
|
||||||
|
/>
|
||||||
|
</MainSlot>
|
||||||
|
{thumbnailTrack && (
|
||||||
|
<Thumbnail>
|
||||||
|
<ParticipantTile
|
||||||
|
key={getTrackKey(thumbnailTrack)}
|
||||||
|
trackRef={thumbnailTrack}
|
||||||
|
disableMetadata
|
||||||
|
/>
|
||||||
|
</Thumbnail>
|
||||||
|
)}
|
||||||
|
</FocusContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PipFocusLayout.displayName = 'PipFocusLayout'
|
||||||
|
|
||||||
|
const FocusContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const MainSlot = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
'& .lk-participant-media-video': {
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Thumbnail = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: '1rem',
|
||||||
|
bottom: '1rem',
|
||||||
|
width: '42%',
|
||||||
|
maxWidth: '220px',
|
||||||
|
minWidth: '140px',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: 'md',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { memo, useMemo, useRef } from 'react'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
|
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||||
|
import { usePipFlipAnimations } from '../../hooks/usePipFlipAnimations'
|
||||||
|
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||||
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
|
type PipGridLayoutProps = {
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptive grid used when 3+ tracks are visible in the PiP window.
|
||||||
|
*
|
||||||
|
* All grid math (shape choice + partial-row stretching) is delegated to
|
||||||
|
* `computePipGridLayout`. This component only measures the container,
|
||||||
|
* applies the returned placements, and plays a FLIP animation when the
|
||||||
|
* tile set or grid shape changes (participant joins/leaves or shape shift).
|
||||||
|
*
|
||||||
|
* Tiles keep a stable key so resizing never remounts <video> elements.
|
||||||
|
*/
|
||||||
|
export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { width, height } = usePipElementSize(containerRef)
|
||||||
|
|
||||||
|
const tileKeys = useMemo(() => tracks.map(getTrackKey), [tracks])
|
||||||
|
|
||||||
|
const { rows, subColumns, placements } = useMemo(
|
||||||
|
() => computePipGridLayout(tracks.length, width, height),
|
||||||
|
[tracks.length, width, height]
|
||||||
|
)
|
||||||
|
|
||||||
|
const gridStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
|
||||||
|
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
||||||
|
}),
|
||||||
|
[subColumns, rows]
|
||||||
|
)
|
||||||
|
|
||||||
|
usePipFlipAnimations(containerRef, tileKeys)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridContainer ref={containerRef} style={gridStyle}>
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<GridCell key={tileKeys[index]} style={placements[index]}>
|
||||||
|
<ParticipantTile trackRef={track} disableMetadata />
|
||||||
|
</GridCell>
|
||||||
|
))}
|
||||||
|
</GridContainer>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
PipGridLayout.displayName = 'PipGridLayout'
|
||||||
|
|
||||||
|
const GridContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.25rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const GridCell = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||||
|
willChange: 'transform',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useTracks } from '@livekit/components-react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import {
|
||||||
|
isCameraTrack,
|
||||||
|
pickLocalCameraTrack,
|
||||||
|
pickRemoteCameraTrack,
|
||||||
|
pickScreenShareTrack,
|
||||||
|
} from '../../utils/pipTrackSelection'
|
||||||
|
import { PipFocusLayout } from './PipFocusLayout'
|
||||||
|
import { PipGridLayout } from './PipGridLayout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this count the PiP stage switches from the focus layout
|
||||||
|
* (main + thumbnail) to the adaptive grid layout.
|
||||||
|
*/
|
||||||
|
const FOCUS_MAX_TILES = 2
|
||||||
|
|
||||||
|
// Handles which layout to render inside the PiP stage.
|
||||||
|
|
||||||
|
export const PipStage = () => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
const tracks = useTracks(
|
||||||
|
[
|
||||||
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
|
],
|
||||||
|
{ onlySubscribed: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
|
||||||
|
|
||||||
|
// Order the list so the "focus target" (screen share when available,
|
||||||
|
// otherwise a remote camera) is first. Both layouts consume this order.
|
||||||
|
const stageTracks = useMemo(() => {
|
||||||
|
const cameraTracks = tracks.filter(isCameraTrack)
|
||||||
|
if (!screenShareTrack) return cameraTracks
|
||||||
|
return [screenShareTrack, ...cameraTracks]
|
||||||
|
}, [tracks, screenShareTrack])
|
||||||
|
|
||||||
|
// avoid tabbing to the stage when it's not visible
|
||||||
|
const frameRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
frameRef.current?.setAttribute('inert', '')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (stageTracks.length === 0) return null
|
||||||
|
|
||||||
|
const stageLabel = t('stage')
|
||||||
|
|
||||||
|
if (stageTracks.length > FOCUS_MAX_TILES) {
|
||||||
|
return (
|
||||||
|
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||||
|
<PipGridLayout tracks={stageTracks} />
|
||||||
|
</StageFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localCameraTrack = pickLocalCameraTrack(stageTracks)
|
||||||
|
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
|
||||||
|
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
|
||||||
|
const thumbnailTrack =
|
||||||
|
localCameraTrack && localCameraTrack !== mainTrack
|
||||||
|
? localCameraTrack
|
||||||
|
: stageTracks.find((track) => track !== mainTrack)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||||
|
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
||||||
|
</StageFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StageFrame = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
margin: '0.5rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||||
|
import { ConnectionState } from 'livekit-client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner surfaced inside the PiP when the room connection degrades.
|
||||||
|
*
|
||||||
|
* Scoped to `Reconnecting` / `Disconnected` - the two states the user needs
|
||||||
|
* to see while their attention is on the PiP rather than the main window.
|
||||||
|
*/
|
||||||
|
export const PipConnectionStateToast = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const state = useConnectionState(room)
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture.connection',
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectionLabels: Partial<Record<ConnectionState, string>> = {
|
||||||
|
[ConnectionState.Reconnecting]: t('reconnecting'),
|
||||||
|
[ConnectionState.Disconnected]: t('disconnected'),
|
||||||
|
}
|
||||||
|
const label = connectionLabels[state] ?? null
|
||||||
|
|
||||||
|
if (!label) return null
|
||||||
|
|
||||||
|
return <Banner role="status">{label}</Banner>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Banner = styled('div', {
|
||||||
|
base: {
|
||||||
|
backgroundColor: 'greyscale.800',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
borderRadius: '6px',
|
||||||
|
boxShadow:
|
||||||
|
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
|
||||||
|
animation: 'fade 200ms',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
|
animation: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useToastQueue } from '@react-stately/toast'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
toastQueue,
|
||||||
|
type ToastData,
|
||||||
|
} from '@/features/notifications/components/ToastProvider'
|
||||||
|
import { PipToastBody } from './PipToastBody'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows shared toasts in the PiP window.
|
||||||
|
* We use a local aria-live region so screen readers can read them in PiP.
|
||||||
|
*/
|
||||||
|
const MAX_VISIBLE = 3
|
||||||
|
|
||||||
|
export const PipNotificationOverlay = () => {
|
||||||
|
const state = useToastQueue<ToastData>(toastQueue)
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (state.visibleToasts.length === 0) return null
|
||||||
|
|
||||||
|
const toasts = state.visibleToasts.slice(0, MAX_VISIBLE)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Region
|
||||||
|
role="region"
|
||||||
|
aria-label={t('notificationsLabel')}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastCard key={toast.key} aria-atomic="true">
|
||||||
|
<PipToastBody toast={toast} />
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
size="sm"
|
||||||
|
invisible
|
||||||
|
aria-label={t('dismissNotification')}
|
||||||
|
onPress={() => state.close(toast.key)}
|
||||||
|
>
|
||||||
|
<RiCloseLine size={16} color="white" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</ToastCard>
|
||||||
|
))}
|
||||||
|
</Region>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Region = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.375rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ToastCard = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
maxWidth: '100%',
|
||||||
|
backgroundColor: 'greyscale.700',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '6px',
|
||||||
|
boxShadow:
|
||||||
|
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
|
||||||
|
paddingRight: '0.25rem',
|
||||||
|
animation: 'fade 200ms',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
|
animation: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { QueuedToast } from '@react-stately/toast'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiHand, RiMessage2Line } from '@remixicon/react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import type { ToastData } from '@/features/notifications/components/ToastProvider'
|
||||||
|
import { RecordingMode } from '@/features/recording'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
toast: QueuedToast<ToastData>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the toast content used in PiP.
|
||||||
|
* PiP stays display-only, so main-window actions are not shown here.
|
||||||
|
*/
|
||||||
|
export const PipToastBody = ({ toast }: Props) => {
|
||||||
|
const { t } = useTranslation('notifications')
|
||||||
|
const { type, participant, message, removedSources } = toast.content
|
||||||
|
const name = participant?.name || t('defaultName')
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.ParticipantJoined:
|
||||||
|
return <Line>{t('joined.description', { name })}</Line>
|
||||||
|
|
||||||
|
case NotificationType.ParticipantMuted:
|
||||||
|
return <Line>{t('muted', { name })}</Line>
|
||||||
|
|
||||||
|
case NotificationType.HandRaised:
|
||||||
|
return (
|
||||||
|
<Line>
|
||||||
|
<RiHand
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
className={iconStyle}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('raised.description', { name })}
|
||||||
|
</Line>
|
||||||
|
)
|
||||||
|
|
||||||
|
case NotificationType.MessageReceived:
|
||||||
|
return (
|
||||||
|
<Line>
|
||||||
|
<RiMessage2Line
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
className={iconStyle}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{name}</strong>
|
||||||
|
{message ? ` - ${message}` : null}
|
||||||
|
</span>
|
||||||
|
</Line>
|
||||||
|
)
|
||||||
|
|
||||||
|
case NotificationType.TranscriptionStarted:
|
||||||
|
return <Line>{t('transcript.started', { name })}</Line>
|
||||||
|
case NotificationType.TranscriptionStopped:
|
||||||
|
return <Line>{t('transcript.stopped', { name })}</Line>
|
||||||
|
case NotificationType.TranscriptionLimitReached:
|
||||||
|
return <Line>{t('transcript.limitReached')}</Line>
|
||||||
|
case NotificationType.TranscriptionRequested:
|
||||||
|
return <Line>{t('transcript.requested', { name })}</Line>
|
||||||
|
|
||||||
|
case NotificationType.ScreenRecordingStarted:
|
||||||
|
return <Line>{t('screenRecording.started', { name })}</Line>
|
||||||
|
case NotificationType.ScreenRecordingStopped:
|
||||||
|
return <Line>{t('screenRecording.stopped', { name })}</Line>
|
||||||
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
|
return <Line>{t('screenRecording.limitReached')}</Line>
|
||||||
|
case NotificationType.ScreenRecordingRequested:
|
||||||
|
return <Line>{t('screenRecording.requested', { name })}</Line>
|
||||||
|
|
||||||
|
case NotificationType.RecordingSaving: {
|
||||||
|
const mode = toast.content.mode as RecordingMode | undefined
|
||||||
|
const key =
|
||||||
|
mode === RecordingMode.ScreenRecording
|
||||||
|
? 'recordingSave.screenRecording.default'
|
||||||
|
: 'recordingSave.transcript.default'
|
||||||
|
return <Line>{t(key)}</Line>
|
||||||
|
}
|
||||||
|
|
||||||
|
case NotificationType.PermissionsRemoved: {
|
||||||
|
const key = resolvePermissionsKey(removedSources)
|
||||||
|
if (!key) return null
|
||||||
|
return <Line>{t(`permissionsRemoved.${key}`)}</Line>
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return message ? <Line>{message}</Line> : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePermissionsKey = (sources: unknown): string | null => {
|
||||||
|
if (!Array.isArray(sources) || sources.length === 0) return null
|
||||||
|
if (sources.length === 1) return sources[0] as string
|
||||||
|
if (sources.includes('screen_share')) return 'screen_share'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const Line = ({ children }: { children: ReactNode }) => (
|
||||||
|
<HStack
|
||||||
|
alignItems="center"
|
||||||
|
gap="0.5rem"
|
||||||
|
padding="0.625rem 0.75rem"
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconStyle = css({ flexShrink: 0 })
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useRef, type ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useFocusManager } from '@react-aria/focus'
|
||||||
|
import { findFirstFocusable } from '@/utils/dom'
|
||||||
|
import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore'
|
||||||
|
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
|
||||||
|
|
||||||
|
const REACTIONS_TOGGLE_ID = 'pip-reactions-toggle'
|
||||||
|
const CONTROL_BAR_ID = 'pip-control-bar'
|
||||||
|
|
||||||
|
const closeToolbar = () => {
|
||||||
|
pipLayoutStore.showReactionsToolbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyboard navigation for the PiP reactions toolbar (mirrors the main app). */
|
||||||
|
export const PipReactionsKeyboardNavigation = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const focusManager = useFocusManager()
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEscapeDismiss(rootRef, true, () => {
|
||||||
|
const doc = rootRef.current?.ownerDocument ?? document
|
||||||
|
doc.getElementById(REACTIONS_TOGGLE_ID)?.focus()
|
||||||
|
closeToolbar()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
const fromOutside = !event.currentTarget.contains(event.relatedTarget)
|
||||||
|
if (fromOutside) focusManager?.focusFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
focusManager?.focusNext({ wrap: true })
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
focusManager?.focusPrevious({ wrap: true })
|
||||||
|
break
|
||||||
|
case 'Tab':
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
const doc = rootRef.current?.ownerDocument ?? document
|
||||||
|
findFirstFocusable(doc.getElementById(CONTROL_BAR_ID))?.focus()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('toolbar')}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton'
|
||||||
|
import {
|
||||||
|
computeReactionsPage,
|
||||||
|
getMaxPageStart,
|
||||||
|
} from '../../utils/pipReactionsPagination'
|
||||||
|
|
||||||
|
type Props = { isOpen: boolean; availableWidth: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated emoji pill with animated entry/exit. Responsibility: layout the
|
||||||
|
* visible emojis for the currently available width and expose prev/next arrows.
|
||||||
|
*/
|
||||||
|
export const PipReactionsPill = ({ isOpen, availableWidth }: Props) => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const [pageStart, setPageStart] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight, visibleCount } =
|
||||||
|
useMemo(
|
||||||
|
() => computeReactionsPage(availableWidth, pageStart),
|
||||||
|
[availableWidth, pageStart]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clamp pageStart if the window was resized and the current page no longer fits.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasOverflow) {
|
||||||
|
setPageStart(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxStart = getMaxPageStart(visibleCount)
|
||||||
|
if (pageStart > maxStart) setPageStart(maxStart)
|
||||||
|
}, [hasOverflow, pageStart, visibleCount])
|
||||||
|
|
||||||
|
const paginate = useCallback((direction: 'left' | 'right') => {
|
||||||
|
setPageStart((current) =>
|
||||||
|
direction === 'left' ? Math.max(0, current - 1) : current + 1
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pill isVisible={isVisible}>
|
||||||
|
{hasOverflow && (
|
||||||
|
<ArrowSlot>
|
||||||
|
{canGoLeft && (
|
||||||
|
<ArrowButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => paginate('left')}
|
||||||
|
aria-label={t('previousReactions')}
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine size={16} />
|
||||||
|
</ArrowButton>
|
||||||
|
)}
|
||||||
|
</ArrowSlot>
|
||||||
|
)}
|
||||||
|
<EmojiRow>
|
||||||
|
{visibleEmojis.map((emoji) => (
|
||||||
|
<ReactionButton key={emoji} emoji={emoji} />
|
||||||
|
))}
|
||||||
|
</EmojiRow>
|
||||||
|
{hasOverflow && (
|
||||||
|
<ArrowSlot>
|
||||||
|
{canGoRight && (
|
||||||
|
<ArrowButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => paginate('right')}
|
||||||
|
aria-label={t('nextReactions')}
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine size={16} />
|
||||||
|
</ArrowButton>
|
||||||
|
)}
|
||||||
|
</ArrowSlot>
|
||||||
|
)}
|
||||||
|
</Pill>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Pill = 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 = styled('button', {
|
||||||
|
base: {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type DocumentPictureInPicture = {
|
||||||
|
requestWindow: (options?: {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}) => Promise<Window>
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowWithDocumentPiP = Window & {
|
||||||
|
documentPictureInPicture?: DocumentPictureInPicture
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDocumentPiP = ({
|
||||||
|
width = 400,
|
||||||
|
height = 480,
|
||||||
|
}: {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
} = {}) => {
|
||||||
|
const [pipWindow, setPipWindow] = useState<Window | null>(null)
|
||||||
|
const pipWindowRef = useRef<Window | null>(null)
|
||||||
|
const pendingPiPRef = useRef<Promise<Window | null> | null>(null)
|
||||||
|
|
||||||
|
const [isSupported] = useState(() => {
|
||||||
|
if (typeof globalThis === 'undefined') return false
|
||||||
|
return 'documentPictureInPicture' in globalThis
|
||||||
|
})
|
||||||
|
|
||||||
|
const openPiP = useCallback(async () => {
|
||||||
|
if (!isSupported) return null
|
||||||
|
const existingWindow = pipWindowRef.current
|
||||||
|
if (existingWindow && !existingWindow.closed) return existingWindow
|
||||||
|
|
||||||
|
if (pendingPiPRef.current) return pendingPiPRef.current
|
||||||
|
|
||||||
|
// Request a new PiP window from the browser API.
|
||||||
|
const pip = (globalThis as unknown as WindowWithDocumentPiP)
|
||||||
|
.documentPictureInPicture
|
||||||
|
if (!pip) return null
|
||||||
|
|
||||||
|
const requestPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const win = await pip.requestWindow({ width, height })
|
||||||
|
const currentWindow = pipWindowRef.current
|
||||||
|
if (currentWindow && !currentWindow.closed) return currentWindow
|
||||||
|
setPipWindow(win)
|
||||||
|
return win
|
||||||
|
} catch (error) {
|
||||||
|
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||||
|
console.error('Failed to open Picture-in-Picture window', error)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
pendingPiPRef.current = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
pendingPiPRef.current = requestPromise
|
||||||
|
return requestPromise
|
||||||
|
}, [height, isSupported, width])
|
||||||
|
|
||||||
|
const closePiP = useCallback(() => {
|
||||||
|
if (!pipWindow) return
|
||||||
|
if (!pipWindow.closed) {
|
||||||
|
pipWindow.close()
|
||||||
|
}
|
||||||
|
setPipWindow(null)
|
||||||
|
}, [pipWindow])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pipWindowRef.current = pipWindow
|
||||||
|
}, [pipWindow])
|
||||||
|
|
||||||
|
// Force-close the native PiP window when the hook unmounts (e.g. the user
|
||||||
|
// hangs up and the room is navigated away before `closePiP` could run).
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const win = pipWindowRef.current
|
||||||
|
if (win && !win.closed) win.close()
|
||||||
|
pipWindowRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pipWindow) return
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setPipWindow(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pipWindow.addEventListener('pagehide', handleClose)
|
||||||
|
pipWindow.addEventListener('beforeunload', handleClose)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pipWindow.removeEventListener('pagehide', handleClose)
|
||||||
|
pipWindow.removeEventListener('beforeunload', handleClose)
|
||||||
|
}
|
||||||
|
}, [pipWindow])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isOpen: !!pipWindow && !pipWindow.closed,
|
||||||
|
pipWindow,
|
||||||
|
openPiP,
|
||||||
|
closePiP,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useRef, type RefObject } from 'react'
|
||||||
|
|
||||||
|
export const useEscapeDismiss = (
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
isActive: boolean,
|
||||||
|
onDismiss: () => void
|
||||||
|
) => {
|
||||||
|
const latestOnDismiss = useRef(onDismiss)
|
||||||
|
useEffect(() => {
|
||||||
|
latestOnDismiss.current = onDismiss
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
latestOnDismiss.current()
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('keydown', handler)
|
||||||
|
return () => el.removeEventListener('keydown', handler)
|
||||||
|
}, [ref, isActive])
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback, useEffect, useState, type RefObject } from 'react'
|
||||||
|
|
||||||
|
type Size = { width: number; height: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes an element's size, even when mounted in the PiP document.
|
||||||
|
* Resolves `ResizeObserver` from the element's own window.
|
||||||
|
*/
|
||||||
|
export const usePipElementSize = <T extends HTMLElement>(
|
||||||
|
ref: RefObject<T | null>
|
||||||
|
): Size => {
|
||||||
|
const [size, setSize] = useState<Size>({ width: 0, height: 0 })
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
setSize({ width: rect.width, height: rect.height })
|
||||||
|
}, [ref])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
measure()
|
||||||
|
|
||||||
|
const RO =
|
||||||
|
el.ownerDocument.defaultView?.ResizeObserver ?? globalThis.ResizeObserver
|
||||||
|
if (!RO) return
|
||||||
|
|
||||||
|
const observer = new RO((entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (!entry) return
|
||||||
|
const { width, height } = entry.contentRect
|
||||||
|
setSize({ width, height })
|
||||||
|
})
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [ref, measure])
|
||||||
|
|
||||||
|
return size
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
/** Animation duration in ms. */
|
||||||
|
duration?: number
|
||||||
|
/** CSS easing function. */
|
||||||
|
easing?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FLIP (First, Last, Invert, Play) animation hook.
|
||||||
|
*
|
||||||
|
* For every keyed direct child of `containerRef`, records its position
|
||||||
|
* before a render (the "first" rect) and, once the DOM has committed, plays
|
||||||
|
* an inverse transform back to the identity position. The effect is a
|
||||||
|
* smooth slide whenever tiles are added, removed, reordered, or a new grid
|
||||||
|
* shape shifts them.
|
||||||
|
*
|
||||||
|
* Safe to call inside a Document PiP window: uses the element's own
|
||||||
|
* Web Animations API (element.animate) which lives in the PiP document.
|
||||||
|
* Respects `prefers-reduced-motion` and no-ops on the first mount.
|
||||||
|
*/
|
||||||
|
export const usePipFlipAnimations = <T extends HTMLElement>(
|
||||||
|
containerRef: RefObject<T | null>,
|
||||||
|
keys: ReadonlyArray<string>,
|
||||||
|
{ duration = 220, easing = 'cubic-bezier(0.2, 0, 0, 1)' }: Options = {}
|
||||||
|
) => {
|
||||||
|
const prevRectsRef = useRef<Map<string, DOMRect>>(new Map())
|
||||||
|
const firstRunRef = useRef(true)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const doc = container.ownerDocument
|
||||||
|
const view = doc.defaultView
|
||||||
|
const reduceMotion = view?.matchMedia(
|
||||||
|
'(prefers-reduced-motion: reduce)'
|
||||||
|
).matches
|
||||||
|
|
||||||
|
const children = Array.from(container.children) as HTMLElement[]
|
||||||
|
const nextRects = new Map<string, DOMRect>()
|
||||||
|
children.forEach((el, i) => {
|
||||||
|
const key = keys[i]
|
||||||
|
if (!key) return
|
||||||
|
nextRects.set(key, el.getBoundingClientRect())
|
||||||
|
})
|
||||||
|
|
||||||
|
if (firstRunRef.current) {
|
||||||
|
firstRunRef.current = false
|
||||||
|
prevRectsRef.current = nextRects
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reduceMotion) {
|
||||||
|
children.forEach((el, i) => {
|
||||||
|
const key = keys[i]
|
||||||
|
if (!key) return
|
||||||
|
const prev = prevRectsRef.current.get(key)
|
||||||
|
const next = nextRects.get(key)
|
||||||
|
if (!prev || !next) return
|
||||||
|
|
||||||
|
const dx = prev.left - next.left
|
||||||
|
const dy = prev.top - next.top
|
||||||
|
const sx = next.width === 0 ? 1 : prev.width / next.width
|
||||||
|
const sy = next.height === 0 ? 1 : prev.height / next.height
|
||||||
|
|
||||||
|
// Skip no-ops: sub-pixel shifts don't benefit from animation.
|
||||||
|
if (
|
||||||
|
Math.abs(dx) < 1 &&
|
||||||
|
Math.abs(dy) < 1 &&
|
||||||
|
Math.abs(sx - 1) < 0.01 &&
|
||||||
|
Math.abs(sy - 1) < 0.01
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
el.animate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
|
||||||
|
},
|
||||||
|
{ transform: 'translate(0, 0) scale(1, 1)' },
|
||||||
|
],
|
||||||
|
{ duration, easing, fill: 'backwards' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prevRectsRef.current = nextRects
|
||||||
|
}, [containerRef, duration, easing, keys])
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react'
|
||||||
|
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||||
|
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||||
|
import { isMacintosh } from '@/utils/livekit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the main-window keyboard shortcuts inside the PiP document.
|
||||||
|
*
|
||||||
|
* The central `useKeyboardShortcuts` hook listens on `window`, which is the
|
||||||
|
* main document's window. Keydown events from the PiP document never reach
|
||||||
|
* it. This hook attaches the same dispatch logic to the PiP document so that
|
||||||
|
* Ctrl+D (mic), Ctrl+E (cam), etc. work identically in both contexts.
|
||||||
|
*/
|
||||||
|
export const usePipKeyboardShortcuts = (
|
||||||
|
containerRef: RefObject<HTMLElement | null>
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const doc = containerRef.current?.ownerDocument
|
||||||
|
if (!doc || doc === document) return
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
const shortcutKey = formatShortcutKey({
|
||||||
|
key,
|
||||||
|
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||||
|
shiftKey,
|
||||||
|
altKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
let handler = keyboardShortcutsStore.shortcuts.get(shortcutKey)
|
||||||
|
if (!handler && shortcutKey === 'ctrl+shift+?') {
|
||||||
|
handler = keyboardShortcutsStore.shortcuts.get('ctrl+shift+/')
|
||||||
|
}
|
||||||
|
if (!handler) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => doc.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [containerRef])
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useRef, type RefObject } from 'react'
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
/** Remap the captured trigger (e.g. when it unmounts on click). */
|
||||||
|
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `useRestoreFocus`: captures and restores focus via the PiP
|
||||||
|
* document instead of the main one.
|
||||||
|
*/
|
||||||
|
export const usePipRestoreFocus = (
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
isOpen: boolean,
|
||||||
|
{ resolveTrigger }: Options = {}
|
||||||
|
) => {
|
||||||
|
const prevOpenRef = useRef(false)
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const doc = ref.current?.ownerDocument
|
||||||
|
const wasOpen = prevOpenRef.current
|
||||||
|
prevOpenRef.current = isOpen
|
||||||
|
|
||||||
|
if (!doc) return
|
||||||
|
|
||||||
|
if (!wasOpen && isOpen) {
|
||||||
|
const activeEl = doc.activeElement as HTMLElement | null
|
||||||
|
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasOpen && !isOpen) {
|
||||||
|
const trigger = triggerRef.current
|
||||||
|
triggerRef.current = null
|
||||||
|
if (trigger && doc.contains(trigger)) {
|
||||||
|
requestAnimationFrame(() => trigger.focus({ preventScroll: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ref, isOpen, resolveTrigger])
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { roomPiPStore } from '@/stores/roomPiP'
|
||||||
|
|
||||||
|
export const useRoomPiP = () => {
|
||||||
|
const { isOpen } = useSnapshot(roomPiPStore)
|
||||||
|
const isSupported =
|
||||||
|
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
||||||
|
|
||||||
|
const open = useCallback(() => {
|
||||||
|
roomPiPStore.isOpen = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
roomPiPStore.isOpen = false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
roomPiPStore.isOpen = !roomPiPStore.isOpen
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isOpen,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
||||||
|
|
||||||
|
type PipLayoutState = {
|
||||||
|
activePanelId: PanelId | null
|
||||||
|
activeSubPanelId: SubPanelId | null
|
||||||
|
showReactionsToolbar: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate layout store for the PiP window.
|
||||||
|
* Decouples PiP side panel state from the main view so opening Chat/Info/etc.
|
||||||
|
* in PiP does not affect the main window and vice versa.
|
||||||
|
*/
|
||||||
|
export const pipLayoutStore = proxy<PipLayoutState>({
|
||||||
|
activePanelId: null,
|
||||||
|
activeSubPanelId: null,
|
||||||
|
showReactionsToolbar: false,
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
export type PipTilePlacement = {
|
||||||
|
gridColumn: string
|
||||||
|
gridRow: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PipGridLayout = {
|
||||||
|
cols: number
|
||||||
|
rows: number
|
||||||
|
/** Number of CSS sub-columns; use as `repeat(subColumns, 1fr)`. */
|
||||||
|
subColumns: number
|
||||||
|
/** One entry per tile, in input order. */
|
||||||
|
placements: PipTilePlacement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target tile aspect ratio used to score candidate grid shapes.
|
||||||
|
*
|
||||||
|
* Video sources are 16:9, but picking 16:9 as the target makes the
|
||||||
|
* scorer indifferent between a stretched 2-col slab (aspect ~2.7) and a
|
||||||
|
* squarer 3-col tile (aspect ~1.2) because log distance is symmetric.
|
||||||
|
* The UI works better with square, face-friendly tiles. This target keeps
|
||||||
|
* wide windows from collapsing to 2 columns with short, stretched rows
|
||||||
|
* and pushes the scorer to add a column instead.
|
||||||
|
*/
|
||||||
|
const TARGET_TILE_ASPECT = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smallest count from which we force at least two columns.
|
||||||
|
* For 1-3 participants it is acceptable to stack vertically in tall
|
||||||
|
* windows, but from 4 people onwards we keep >=2 columns to
|
||||||
|
* avoid endless vertical scrolling; the scorer handles the rest.
|
||||||
|
*/
|
||||||
|
const FORCE_TWO_COLS_COUNT = 4
|
||||||
|
|
||||||
|
const pickGridShape = (
|
||||||
|
count: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): { cols: number; rows: number } => {
|
||||||
|
if (count <= 1) return { cols: 1, rows: Math.max(1, count) }
|
||||||
|
if (width <= 0 || height <= 0) return { cols: count, rows: 1 }
|
||||||
|
|
||||||
|
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
|
||||||
|
|
||||||
|
let best = {
|
||||||
|
cols: minCols,
|
||||||
|
rows: Math.ceil(count / minCols),
|
||||||
|
score: -Infinity,
|
||||||
|
}
|
||||||
|
for (let cols = minCols; cols <= count; cols++) {
|
||||||
|
const rows = Math.ceil(count / cols)
|
||||||
|
const tileW = width / cols
|
||||||
|
const tileH = height / rows
|
||||||
|
if (tileW <= 0 || tileH <= 0) continue
|
||||||
|
|
||||||
|
// Score: aspect close to target, few empty cells, large tile area,
|
||||||
|
// and a tiny bias toward fewer rows so ties (perfectly square shapes)
|
||||||
|
// resolve in favour of a shorter, wider grid.
|
||||||
|
const aspectScore = -Math.abs(Math.log(tileW / tileH / TARGET_TILE_ASPECT))
|
||||||
|
const emptyCells = cols * rows - count
|
||||||
|
const fillScore = -emptyCells * 0.1
|
||||||
|
const areaScore = Math.log(tileW * tileH) * 0.5
|
||||||
|
const rowsPenalty = -rows * 0.01
|
||||||
|
|
||||||
|
const score = aspectScore * 2 + fillScore + areaScore + rowsPenalty
|
||||||
|
if (score > best.score) best = { cols, rows, score }
|
||||||
|
}
|
||||||
|
return { cols: best.cols, rows: best.rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function. Given a tile count and stage dimensions, returns the CSS
|
||||||
|
* grid layout for the PiP stage:
|
||||||
|
*
|
||||||
|
* - picks a cols x rows shape close to 16:9 tiles,
|
||||||
|
* - stretches any partial last row so its tiles share the full row width
|
||||||
|
* (no empty cells, no small centered tile).
|
||||||
|
*
|
||||||
|
* Callers consume the result directly: `subColumns` feeds
|
||||||
|
* `grid-template-columns: repeat(N, 1fr)` and each tile reads its own
|
||||||
|
* `gridColumn`/`gridRow` from `placements`.
|
||||||
|
*/
|
||||||
|
export const computePipGridLayout = (
|
||||||
|
count: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): PipGridLayout => {
|
||||||
|
if (count <= 0) {
|
||||||
|
return { cols: 1, rows: 1, subColumns: 1, placements: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cols, rows } = pickGridShape(count, width, height)
|
||||||
|
const tilesInLastRow = count - cols * (rows - 1)
|
||||||
|
const hasPartialRow = tilesInLastRow > 0 && tilesInLastRow < cols
|
||||||
|
|
||||||
|
const subColumns = hasPartialRow ? cols * tilesInLastRow : cols
|
||||||
|
const fullRowSpan = hasPartialRow ? tilesInLastRow : 1
|
||||||
|
const lastRowSpan = hasPartialRow ? cols : 1
|
||||||
|
|
||||||
|
const placements: PipTilePlacement[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const row = Math.floor(i / cols)
|
||||||
|
const colIndex = i % cols
|
||||||
|
const isLastRow = row === rows - 1 && hasPartialRow
|
||||||
|
const span = isLastRow ? lastRowSpan : fullRowSpan
|
||||||
|
const colStart = colIndex * span + 1
|
||||||
|
placements.push({
|
||||||
|
gridColumn: `${colStart} / span ${span}`,
|
||||||
|
gridRow: row + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cols, rows, subColumns, placements }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Emoji } from '@/features/reactions/types'
|
||||||
|
|
||||||
|
export const EMOJI_SLOT_WIDTH = 40
|
||||||
|
export const ARROW_SLOT_WIDTH = 32
|
||||||
|
export const PILL_HORIZONTAL_PADDING = 12
|
||||||
|
export const WRAPPER_HORIZONTAL_PADDING = 16
|
||||||
|
|
||||||
|
const EMOJIS = Object.values(Emoji)
|
||||||
|
|
||||||
|
export type ReactionsPage = {
|
||||||
|
visibleEmojis: Emoji[]
|
||||||
|
hasOverflow: boolean
|
||||||
|
canGoLeft: boolean
|
||||||
|
canGoRight: boolean
|
||||||
|
visibleCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute how many emojis fit in `availableWidth` and slice the visible page.
|
||||||
|
* Arrow slots are reserved only when the list overflows.
|
||||||
|
*/
|
||||||
|
export const computeReactionsPage = (
|
||||||
|
availableWidth: number,
|
||||||
|
pageStart: number
|
||||||
|
): ReactionsPage => {
|
||||||
|
const usableWidth =
|
||||||
|
availableWidth - WRAPPER_HORIZONTAL_PADDING - PILL_HORIZONTAL_PADDING
|
||||||
|
const maxWithoutArrows = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(usableWidth / EMOJI_SLOT_WIDTH)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (EMOJIS.length <= maxWithoutArrows) {
|
||||||
|
return {
|
||||||
|
visibleEmojis: EMOJIS,
|
||||||
|
hasOverflow: false,
|
||||||
|
canGoLeft: false,
|
||||||
|
canGoRight: false,
|
||||||
|
visibleCount: EMOJIS.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((usableWidth - ARROW_SLOT_WIDTH * 2) / EMOJI_SLOT_WIDTH)
|
||||||
|
)
|
||||||
|
const clampedStart = Math.min(
|
||||||
|
Math.max(0, pageStart),
|
||||||
|
Math.max(0, EMOJIS.length - visibleCount)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
|
||||||
|
hasOverflow: true,
|
||||||
|
canGoLeft: clampedStart > 0,
|
||||||
|
canGoRight: clampedStart + visibleCount < EMOJIS.length,
|
||||||
|
visibleCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMaxPageStart = (visibleCount: number): number =>
|
||||||
|
Math.max(0, EMOJIS.length - visibleCount)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
isTrackReference,
|
||||||
|
TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers used by the PiP layouts to classify/pick tracks.
|
||||||
|
* Kept free of React so they are trivially testable and cheap to call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const pickScreenShareTrack = (
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
): TrackReferenceOrPlaceholder | undefined =>
|
||||||
|
tracks
|
||||||
|
.filter((track) => isTrackReference(track))
|
||||||
|
.find((track) => track.publication.source === Track.Source.ScreenShare)
|
||||||
|
|
||||||
|
export const pickLocalCameraTrack = (
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
): TrackReferenceOrPlaceholder | undefined =>
|
||||||
|
tracks.find(
|
||||||
|
(track) =>
|
||||||
|
track.source === Track.Source.Camera && track.participant?.isLocal
|
||||||
|
)
|
||||||
|
|
||||||
|
export const pickRemoteCameraTrack = (
|
||||||
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
): TrackReferenceOrPlaceholder | undefined =>
|
||||||
|
tracks.find(
|
||||||
|
(track) =>
|
||||||
|
track.source === Track.Source.Camera && !track.participant?.isLocal
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isCameraTrack = (track: TrackReferenceOrPlaceholder): boolean =>
|
||||||
|
track.source === Track.Source.Camera
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a stable React key for a track so resizes/reshuffles of the grid
|
||||||
|
* do not remount the underlying <video> element.
|
||||||
|
*/
|
||||||
|
export const getTrackKey = (track: TrackReferenceOrPlaceholder): string => {
|
||||||
|
const identity = track.participant?.identity ?? 'unknown'
|
||||||
|
if (isTrackReference(track)) {
|
||||||
|
return `${identity}::${track.source}::${track.publication.trackSid}`
|
||||||
|
}
|
||||||
|
return `${identity}::${track.source}::placeholder`
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Heading } from 'react-aria-components'
|
import { Heading } from 'react-aria-components'
|
||||||
import { text } from '@/primitives/Text'
|
import { text } from '@/primitives/Text'
|
||||||
@@ -6,7 +5,7 @@ import { Button, Div } from '@/primitives'
|
|||||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { type SidePanelStore, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
@@ -144,7 +143,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
|||||||
{keepAlive || isOpen ? children : null}
|
{keepAlive || isOpen ? children : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
export const SidePanel = () => {
|
export const SidePanel = ({ store }: { store?: SidePanelStore }) => {
|
||||||
const {
|
const {
|
||||||
activePanelId,
|
activePanelId,
|
||||||
isParticipantsOpen,
|
isParticipantsOpen,
|
||||||
@@ -156,7 +155,9 @@ export const SidePanel = () => {
|
|||||||
isInfoOpen,
|
isInfoOpen,
|
||||||
isSubPanelOpen,
|
isSubPanelOpen,
|
||||||
activeSubPanelId,
|
activeSubPanelId,
|
||||||
} = useSidePanel()
|
closePanel,
|
||||||
|
goBack,
|
||||||
|
} = useSidePanel(store)
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||||
|
|
||||||
@@ -166,10 +167,7 @@ export const SidePanel = () => {
|
|||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={title}
|
title={title}
|
||||||
ariaLabel={t('ariaLabel', { title })}
|
ariaLabel={t('ariaLabel', { title })}
|
||||||
onClose={() => {
|
onClose={closePanel}
|
||||||
layoutStore.activePanelId = null
|
|
||||||
layoutStore.activeSubPanelId = null
|
|
||||||
}}
|
|
||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||||
})}
|
})}
|
||||||
@@ -177,7 +175,7 @@ export const SidePanel = () => {
|
|||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||||
backButtonLabel={t('backToTools')}
|
backButtonLabel={t('backToTools')}
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={goBack}
|
||||||
>
|
>
|
||||||
<Panel isOpen={isParticipantsOpen}>
|
<Panel isOpen={isParticipantsOpen}>
|
||||||
<ParticipantsList />
|
<ParticipantsList />
|
||||||
|
|||||||
+3
-3
@@ -2,11 +2,11 @@ import { RiImageCircleAiFill } from '@remixicon/react'
|
|||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { type SidePanelStore, useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
|
|
||||||
export const EffectsMenuItem = () => {
|
export const EffectsMenuItem = ({ store }: { store?: SidePanelStore }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { toggleEffects } = useSidePanel()
|
const { toggleEffects } = useSidePanel(store)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
+2
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
|
|||||||
import { SupportMenuItem } from './SupportMenuItem'
|
import { SupportMenuItem } from './SupportMenuItem'
|
||||||
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
||||||
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
||||||
|
import { PictureInPictureMenuItem } from './PictureInPictureMenuItem'
|
||||||
|
|
||||||
// @todo try refactoring it to use MenuList component
|
// @todo try refactoring it to use MenuList component
|
||||||
export const OptionsMenuItems = () => {
|
export const OptionsMenuItems = () => {
|
||||||
@@ -21,6 +22,7 @@ export const OptionsMenuItems = () => {
|
|||||||
<TranscriptMenuItem />
|
<TranscriptMenuItem />
|
||||||
<ScreenRecordingMenuItem />
|
<ScreenRecordingMenuItem />
|
||||||
<FullScreenMenuItem />
|
<FullScreenMenuItem />
|
||||||
|
<PictureInPictureMenuItem />
|
||||||
<EffectsMenuItem />
|
<EffectsMenuItem />
|
||||||
</MenuSection>
|
</MenuSection>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiPictureInPicture2Line } from '@remixicon/react'
|
||||||
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
|
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||||
|
|
||||||
|
export const PictureInPictureMenuItem = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
|
const { isSupported, isOpen, toggle } = useRoomPiP()
|
||||||
|
|
||||||
|
// Hide the entry when the browser doesn't support Document PiP.
|
||||||
|
if (!isSupported) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
onAction={toggle}
|
||||||
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
|
>
|
||||||
|
<RiPictureInPicture2Line size={20} />
|
||||||
|
{isOpen ? t('pictureInPicture.exit') : t('pictureInPicture.enter')}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,74 +1,77 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { PanelId, SubPanelId } from '../types/panel'
|
||||||
|
|
||||||
export enum PanelId {
|
export { PanelId, SubPanelId } from '../types/panel'
|
||||||
PARTICIPANTS = 'participants',
|
|
||||||
EFFECTS = 'effects',
|
export type SidePanelStore = {
|
||||||
CHAT = 'chat',
|
activePanelId: PanelId | null
|
||||||
TOOLS = 'tools',
|
activeSubPanelId: SubPanelId | null
|
||||||
ADMIN = 'admin',
|
|
||||||
INFO = 'info',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SubPanelId {
|
export const useSidePanel = (store: SidePanelStore = layoutStore) => {
|
||||||
TRANSCRIPT = 'transcript',
|
const layoutSnap = useSnapshot(store)
|
||||||
SCREEN_RECORDING = 'screenRecording',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSidePanel = () => {
|
|
||||||
const layoutSnap = useSnapshot(layoutStore)
|
|
||||||
const activePanelId = layoutSnap.activePanelId
|
const activePanelId = layoutSnap.activePanelId
|
||||||
const activeSubPanelId = layoutSnap.activeSubPanelId
|
const activeSubPanelId = layoutSnap.activeSubPanelId
|
||||||
|
|
||||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
const isParticipantsOpen = activePanelId === PanelId.PARTICIPANTS
|
||||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
const isEffectsOpen = activePanelId === PanelId.EFFECTS
|
||||||
const isChatOpen = activePanelId == PanelId.CHAT
|
const isChatOpen = activePanelId === PanelId.CHAT
|
||||||
const isToolsOpen = activePanelId == PanelId.TOOLS
|
const isToolsOpen = activePanelId === PanelId.TOOLS
|
||||||
const isAdminOpen = activePanelId == PanelId.ADMIN
|
const isAdminOpen = activePanelId === PanelId.ADMIN
|
||||||
const isInfoOpen = activePanelId == PanelId.INFO
|
const isInfoOpen = activePanelId === PanelId.INFO
|
||||||
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
|
const isTranscriptOpen = activeSubPanelId === SubPanelId.TRANSCRIPT
|
||||||
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
|
const isScreenRecordingOpen = activeSubPanelId === SubPanelId.SCREEN_RECORDING
|
||||||
const isSidePanelOpen = !!activePanelId
|
const isSidePanelOpen = !!activePanelId
|
||||||
const isSubPanelOpen = !!activeSubPanelId
|
const isSubPanelOpen = !!activeSubPanelId
|
||||||
|
|
||||||
const toggleAdmin = () => {
|
const toggleAdmin = () => {
|
||||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
store.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleParticipants = () => {
|
const toggleParticipants = () => {
|
||||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
store.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleChat = () => {
|
const toggleChat = () => {
|
||||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
store.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleEffects = () => {
|
const toggleEffects = () => {
|
||||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
store.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleTools = () => {
|
const toggleTools = () => {
|
||||||
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
store.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleInfo = () => {
|
const toggleInfo = () => {
|
||||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
store.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTranscript = () => {
|
const openTranscript = () => {
|
||||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
store.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||||
layoutStore.activePanelId = PanelId.TOOLS
|
store.activePanelId = PanelId.TOOLS
|
||||||
}
|
}
|
||||||
|
|
||||||
const openScreenRecording = () => {
|
const openScreenRecording = () => {
|
||||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
store.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||||
layoutStore.activePanelId = PanelId.TOOLS
|
store.activePanelId = PanelId.TOOLS
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePanel = () => {
|
||||||
|
store.activePanelId = null
|
||||||
|
store.activeSubPanelId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
store.activeSubPanelId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -82,6 +85,8 @@ export const useSidePanel = () => {
|
|||||||
toggleInfo,
|
toggleInfo,
|
||||||
openTranscript,
|
openTranscript,
|
||||||
openScreenRecording,
|
openScreenRecording,
|
||||||
|
closePanel,
|
||||||
|
goBack,
|
||||||
isSubPanelOpen,
|
isSubPanelOpen,
|
||||||
isChatOpen,
|
isChatOpen,
|
||||||
isParticipantsOpen,
|
isParticipantsOpen,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
|||||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
|
import { RoomPiP } from '@/features/pip/components/RoomPiP'
|
||||||
|
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||||
@@ -227,6 +229,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
])
|
])
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||||
|
const shouldRenderMainLayout = !isPiPOpen
|
||||||
|
|
||||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -248,32 +253,36 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
/>
|
/>
|
||||||
<IsIdleDisconnectModal />
|
<IsIdleDisconnectModal />
|
||||||
<RoomContentArea>
|
<RoomContentArea>
|
||||||
{!focusTrack ? (
|
{shouldRenderMainLayout && (
|
||||||
<div
|
<>
|
||||||
className="lk-grid-layout-wrapper"
|
{!focusTrack ? (
|
||||||
style={{ height: 'auto' }}
|
<div
|
||||||
>
|
className="lk-grid-layout-wrapper"
|
||||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
style={{ height: 'auto' }}
|
||||||
<ParticipantTile />
|
|
||||||
</GridLayout>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="lk-focus-layout-wrapper"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
>
|
|
||||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
||||||
<CarouselLayout
|
|
||||||
tracks={carouselTracks}
|
|
||||||
style={{
|
|
||||||
minWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ParticipantTile />
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||||
</CarouselLayout>
|
<ParticipantTile />
|
||||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
</GridLayout>
|
||||||
</FocusLayoutContainer>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div
|
||||||
|
className="lk-focus-layout-wrapper"
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||||
|
<CarouselLayout
|
||||||
|
tracks={carouselTracks}
|
||||||
|
style={{
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</RoomContentArea>
|
</RoomContentArea>
|
||||||
<ControlBar
|
<ControlBar
|
||||||
@@ -289,6 +298,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SidePanel />
|
<SidePanel />
|
||||||
|
<RoomPiP />
|
||||||
</LayoutContextProvider>
|
</LayoutContextProvider>
|
||||||
)}
|
)}
|
||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Panel identifiers for the side panel (Info, Chat, Participants, etc.).
|
||||||
|
* Extracted to avoid circular dependencies between layout store and useSidePanel.
|
||||||
|
*/
|
||||||
|
export enum PanelId {
|
||||||
|
PARTICIPANTS = 'participants',
|
||||||
|
EFFECTS = 'effects',
|
||||||
|
CHAT = 'chat',
|
||||||
|
TOOLS = 'tools',
|
||||||
|
ADMIN = 'admin',
|
||||||
|
INFO = 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SubPanelId {
|
||||||
|
TRANSCRIPT = 'transcript',
|
||||||
|
SCREEN_RECORDING = 'screenRecording',
|
||||||
|
}
|
||||||
@@ -241,6 +241,23 @@
|
|||||||
"username": "Deinen Namen aktualisieren",
|
"username": "Deinen Namen aktualisieren",
|
||||||
"effects": "Effekte anwenden",
|
"effects": "Effekte anwenden",
|
||||||
"switchCamera": "Kamera wechseln",
|
"switchCamera": "Kamera wechseln",
|
||||||
|
"pictureInPicture": {
|
||||||
|
"enter": "Bild-im-Bild",
|
||||||
|
"exit": "Bild-im-Bild schließen",
|
||||||
|
"opened": "Bild-im-Bild-Modus aktiviert",
|
||||||
|
"closed": "Bild-im-Bild-Modus deaktiviert",
|
||||||
|
"windowLabel": "Bild-im-Bild Besprechung",
|
||||||
|
"stage": "Teilnehmer",
|
||||||
|
"controlBar": "Besprechungssteuerung",
|
||||||
|
"previousReactions": "Vorherige Reaktionen",
|
||||||
|
"nextReactions": "Nächste Reaktionen",
|
||||||
|
"notificationsLabel": "Benachrichtigungen",
|
||||||
|
"dismissNotification": "Benachrichtigung schließen",
|
||||||
|
"connection": {
|
||||||
|
"reconnecting": "Verbindung wird wiederhergestellt…",
|
||||||
|
"disconnected": "Verbindung getrennt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullscreen": {
|
"fullscreen": {
|
||||||
"enter": "Vollbild",
|
"enter": "Vollbild",
|
||||||
"exit": "Vollbildmodus verlassen"
|
"exit": "Vollbildmodus verlassen"
|
||||||
|
|||||||
@@ -241,6 +241,23 @@
|
|||||||
"username": "Update Your Name",
|
"username": "Update Your Name",
|
||||||
"effects": "Backgrounds and Effects",
|
"effects": "Backgrounds and Effects",
|
||||||
"switchCamera": "Switch camera",
|
"switchCamera": "Switch camera",
|
||||||
|
"pictureInPicture": {
|
||||||
|
"enter": "Picture-in-picture",
|
||||||
|
"exit": "Close picture-in-picture",
|
||||||
|
"opened": "Picture-in-picture mode enabled",
|
||||||
|
"closed": "Picture-in-picture mode disabled",
|
||||||
|
"windowLabel": "Picture-in-picture meeting",
|
||||||
|
"stage": "Participants",
|
||||||
|
"controlBar": "Meeting controls",
|
||||||
|
"previousReactions": "Previous reactions",
|
||||||
|
"nextReactions": "Next reactions",
|
||||||
|
"notificationsLabel": "Notifications",
|
||||||
|
"dismissNotification": "Dismiss notification",
|
||||||
|
"connection": {
|
||||||
|
"reconnecting": "Reconnecting…",
|
||||||
|
"disconnected": "Disconnected"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullscreen": {
|
"fullscreen": {
|
||||||
"enter": "Fullscreen",
|
"enter": "Fullscreen",
|
||||||
"exit": "Exit fullscreen mode"
|
"exit": "Exit fullscreen mode"
|
||||||
|
|||||||
@@ -241,6 +241,23 @@
|
|||||||
"username": "Choisir votre nom",
|
"username": "Choisir votre nom",
|
||||||
"effects": "Arrière-plans et effets",
|
"effects": "Arrière-plans et effets",
|
||||||
"switchCamera": "Changer de caméra",
|
"switchCamera": "Changer de caméra",
|
||||||
|
"pictureInPicture": {
|
||||||
|
"enter": "Image dans l'image",
|
||||||
|
"exit": "Fermer l'image dans l'image",
|
||||||
|
"opened": "Mode image dans l'image activé",
|
||||||
|
"closed": "Mode image dans l'image désactivé",
|
||||||
|
"windowLabel": "Réunion en image dans l'image",
|
||||||
|
"stage": "Participants",
|
||||||
|
"controlBar": "Commandes de la réunion",
|
||||||
|
"previousReactions": "Réactions précédentes",
|
||||||
|
"nextReactions": "Réactions suivantes",
|
||||||
|
"notificationsLabel": "Notifications",
|
||||||
|
"dismissNotification": "Fermer la notification",
|
||||||
|
"connection": {
|
||||||
|
"reconnecting": "Reconnexion…",
|
||||||
|
"disconnected": "Déconnecté"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullscreen": {
|
"fullscreen": {
|
||||||
"enter": "Plein écran",
|
"enter": "Plein écran",
|
||||||
"exit": "Quitter le mode plein écran"
|
"exit": "Quitter le mode plein écran"
|
||||||
|
|||||||
@@ -241,6 +241,23 @@
|
|||||||
"username": "Verander uw naam",
|
"username": "Verander uw naam",
|
||||||
"effects": "Pas effecten toe",
|
"effects": "Pas effecten toe",
|
||||||
"switchCamera": "Selecteer camera",
|
"switchCamera": "Selecteer camera",
|
||||||
|
"pictureInPicture": {
|
||||||
|
"enter": "Beeld-in-beeld",
|
||||||
|
"exit": "Beeld-in-beeld sluiten",
|
||||||
|
"opened": "Beeld-in-beeld-modus ingeschakeld",
|
||||||
|
"closed": "Beeld-in-beeld-modus uitgeschakeld",
|
||||||
|
"windowLabel": "Beeld-in-beeld vergadering",
|
||||||
|
"stage": "Deelnemers",
|
||||||
|
"controlBar": "Vergaderbesturing",
|
||||||
|
"previousReactions": "Vorige reacties",
|
||||||
|
"nextReactions": "Volgende reacties",
|
||||||
|
"notificationsLabel": "Meldingen",
|
||||||
|
"dismissNotification": "Melding sluiten",
|
||||||
|
"connection": {
|
||||||
|
"reconnecting": "Opnieuw verbinden…",
|
||||||
|
"disconnected": "Verbinding verbroken"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fullscreen": {
|
"fullscreen": {
|
||||||
"enter": "Volledig scherm",
|
"enter": "Volledig scherm",
|
||||||
"exit": "Stop volledig scherm stand"
|
"exit": "Stop volledig scherm stand"
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode, useMemo } from 'react'
|
||||||
import { MenuTrigger } from 'react-aria-components'
|
import { MenuTrigger } from 'react-aria-components'
|
||||||
import { StyledPopover } from './Popover'
|
import { StyledPopover } from './Popover'
|
||||||
import { Box } from './Box'
|
import { Box } from './Box'
|
||||||
|
import {
|
||||||
|
useOverlayBoundaryElement,
|
||||||
|
useOverlayPortalContainer,
|
||||||
|
} from './useOverlayPortalContainer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
|
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
|
||||||
|
*
|
||||||
|
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
|
||||||
*/
|
*/
|
||||||
export const Menu = ({
|
export const Menu = ({
|
||||||
children,
|
children,
|
||||||
@@ -16,10 +22,30 @@ export const Menu = ({
|
|||||||
placement?: 'bottom' | 'top' | 'left' | 'right'
|
placement?: 'bottom' | 'top' | 'left' | 'right'
|
||||||
}) => {
|
}) => {
|
||||||
const [trigger, menu] = children
|
const [trigger, menu] = children
|
||||||
|
// const boundaryElement = useOverlayBoundaryElement()
|
||||||
|
// const portalContainer = useOverlayPortalContainer()
|
||||||
|
|
||||||
|
// // Detect if we're in PiP: portal container is in a different document than the main window
|
||||||
|
// const isInPiP = useMemo(
|
||||||
|
// () =>
|
||||||
|
// portalContainer &&
|
||||||
|
// portalContainer.ownerDocument &&
|
||||||
|
// portalContainer.ownerDocument !== document,
|
||||||
|
// [portalContainer]
|
||||||
|
// )
|
||||||
|
|
||||||
|
// Default placement: 'bottom' in PiP, 'top' elsewhere (to match existing behavior)
|
||||||
|
// const defaultPlacement = isInPiP ? 'bottom' : 'top'
|
||||||
|
// const shouldFlip = isInPiP ? false : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
{trigger}
|
{trigger}
|
||||||
<StyledPopover placement={placement}>
|
<StyledPopover
|
||||||
|
placement={placement}
|
||||||
|
// shouldFlip={shouldFlip}
|
||||||
|
// boundaryElement={boundaryElement}
|
||||||
|
>
|
||||||
<Box size="sm" type="popover" variant={variant}>
|
<Box size="sm" type="popover" variant={variant}>
|
||||||
{menu}
|
{menu}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { Box } from './Box'
|
import { Box } from './Box'
|
||||||
|
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
|
||||||
|
|
||||||
export const StyledPopover = styled(RACPopover, {
|
export const StyledPopover = styled(RACPopover, {
|
||||||
base: {
|
base: {
|
||||||
@@ -65,6 +66,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
|||||||
*
|
*
|
||||||
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
|
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
|
||||||
* This is here when needing to show unrestricted content in a box.
|
* This is here when needing to show unrestricted content in a box.
|
||||||
|
*
|
||||||
|
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
|
||||||
*/
|
*/
|
||||||
export const Popover = ({
|
export const Popover = ({
|
||||||
children,
|
children,
|
||||||
@@ -82,10 +85,11 @@ export const Popover = ({
|
|||||||
withArrow?: boolean
|
withArrow?: boolean
|
||||||
} & Omit<DialogProps, 'children'>) => {
|
} & Omit<DialogProps, 'children'>) => {
|
||||||
const [trigger, popoverContent] = children
|
const [trigger, popoverContent] = children
|
||||||
|
const boundaryElement = useOverlayBoundaryElement()
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
{trigger}
|
{trigger}
|
||||||
<StyledPopover>
|
<StyledPopover boundaryElement={boundaryElement}>
|
||||||
{withArrow && (
|
{withArrow && (
|
||||||
<StyledOverlayArrow variant={variant}>
|
<StyledOverlayArrow variant={variant}>
|
||||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import {
|
|||||||
type TooltipProps,
|
type TooltipProps,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { useOverlayPortalContainer } from './useOverlayPortalContainer'
|
||||||
|
import { VisualOnlyTooltip } from './VisualOnlyTooltip'
|
||||||
|
|
||||||
export type TooltipWrapperProps = {
|
export type TooltipWrapperProps = {
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
tooltipType?: 'instant' | 'delayed'
|
tooltipType?: 'instant' | 'delayed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INSTANT_TOOLTIP_DELAY_MS = 150
|
||||||
|
const DELAYED_TOOLTIP_DELAY_MS = 1000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a component you want to apply a tooltip on (for example a Button)
|
* Wrap a component you want to apply a tooltip on (for example a Button)
|
||||||
*
|
*
|
||||||
@@ -24,11 +29,25 @@ export const TooltipWrapper = ({
|
|||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
} & TooltipWrapperProps) => {
|
} & TooltipWrapperProps) => {
|
||||||
|
const portalContainer = useOverlayPortalContainer()
|
||||||
|
const isExternalDocument =
|
||||||
|
portalContainer && portalContainer.ownerDocument !== document
|
||||||
|
|
||||||
return tooltip ? (
|
return tooltip ? (
|
||||||
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
|
isExternalDocument ? (
|
||||||
{children}
|
<VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
|
||||||
<Tooltip>{tooltip}</Tooltip>
|
) : (
|
||||||
</TooltipTrigger>
|
<TooltipTrigger
|
||||||
|
delay={
|
||||||
|
tooltipType === 'instant'
|
||||||
|
? INSTANT_TOOLTIP_DELAY_MS
|
||||||
|
: DELAYED_TOOLTIP_DELAY_MS
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Tooltip>{tooltip}</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)
|
)
|
||||||
@@ -39,6 +58,8 @@ export const TooltipWrapper = ({
|
|||||||
*
|
*
|
||||||
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
|
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
|
||||||
*/
|
*/
|
||||||
|
const DEFAULT_TOOLTIP_GAP_PX = 8
|
||||||
|
|
||||||
const StyledTooltip = styled(RACTooltip, {
|
const StyledTooltip = styled(RACTooltip, {
|
||||||
base: {
|
base: {
|
||||||
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
||||||
@@ -53,11 +74,11 @@ const StyledTooltip = styled(RACTooltip, {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
transform: 'translate3d(0, 0, 0)',
|
transform: 'translate3d(0, 0, 0)',
|
||||||
'&[data-placement=top]': {
|
'&[data-placement=top]': {
|
||||||
marginBottom: '8px',
|
marginBottom: `${DEFAULT_TOOLTIP_GAP_PX}px`,
|
||||||
'--origin': 'translateY(4px)',
|
'--origin': 'translateY(4px)',
|
||||||
},
|
},
|
||||||
'&[data-placement=bottom]': {
|
'&[data-placement=bottom]': {
|
||||||
marginTop: '8px',
|
marginTop: `${DEFAULT_TOOLTIP_GAP_PX}px`,
|
||||||
'--origin': 'translateY(-4px)',
|
'--origin': 'translateY(-4px)',
|
||||||
},
|
},
|
||||||
'&[data-placement=right]': {
|
'&[data-placement=right]': {
|
||||||
@@ -107,10 +128,13 @@ const TooltipArrow = () => {
|
|||||||
|
|
||||||
const Tooltip = ({
|
const Tooltip = ({
|
||||||
children,
|
children,
|
||||||
|
arrowBoundaryOffset,
|
||||||
...props
|
...props
|
||||||
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
} & Partial<Omit<TooltipProps, 'children'>>) => {
|
||||||
return (
|
return (
|
||||||
<StyledTooltip {...props}>
|
<StyledTooltip arrowBoundaryOffset={arrowBoundaryOffset ?? 0} {...props}>
|
||||||
<TooltipArrow />
|
<TooltipArrow />
|
||||||
{children}
|
{children}
|
||||||
</StyledTooltip>
|
</StyledTooltip>
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import {
|
|||||||
type ReactElement,
|
type ReactElement,
|
||||||
cloneElement,
|
cloneElement,
|
||||||
isValidElement,
|
isValidElement,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
|
||||||
|
|
||||||
export type VisualOnlyTooltipProps = {
|
export type VisualOnlyTooltipProps = {
|
||||||
children: ReactElement
|
children: ReactElement
|
||||||
@@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({
|
|||||||
tooltipPosition = 'top',
|
tooltipPosition = 'top',
|
||||||
}: VisualOnlyTooltipProps) => {
|
}: VisualOnlyTooltipProps) => {
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const { getContainer } = useUNSAFE_PortalContext()
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||||
const [position, setPosition] = useState<{
|
const [position, setPosition] = useState<{
|
||||||
top: number
|
top: number
|
||||||
left: number
|
left: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const [computedStyle, setComputedStyle] = useState<{
|
||||||
|
left: number
|
||||||
|
arrowLeft: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const isBottom = tooltipPosition === 'bottom'
|
const [effectiveBottom, setEffectiveBottom] = useState(
|
||||||
|
tooltipPosition === 'bottom'
|
||||||
|
)
|
||||||
|
|
||||||
const showTooltip = () => {
|
const showTooltip = () => {
|
||||||
if (!wrapperRef.current) return
|
if (!wrapperRef.current) return
|
||||||
const rect = wrapperRef.current.getBoundingClientRect()
|
const rect = wrapperRef.current.getBoundingClientRect()
|
||||||
|
const preferBottom = tooltipPosition === 'bottom'
|
||||||
|
setEffectiveBottom(preferBottom)
|
||||||
setPosition({
|
setPosition({
|
||||||
top: isBottom ? rect.bottom + 8 : rect.top - 8,
|
top: preferBottom ? rect.bottom + 8 : rect.top - 8,
|
||||||
left: rect.left + rect.width / 2,
|
left: rect.left + rect.width / 2,
|
||||||
})
|
})
|
||||||
setIsVisible(true)
|
setIsVisible(true)
|
||||||
@@ -53,9 +66,47 @@ export const VisualOnlyTooltip = ({
|
|||||||
const hideTooltip = () => {
|
const hideTooltip = () => {
|
||||||
setIsVisible(false)
|
setIsVisible(false)
|
||||||
setPosition(null)
|
setPosition(null)
|
||||||
|
setComputedStyle(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipData = isVisible && position ? { isVisible, position } : null
|
useLayoutEffect(() => {
|
||||||
|
if (!tooltipRef.current || !wrapperRef.current || !isVisible || !position)
|
||||||
|
return
|
||||||
|
const tooltipRect = tooltipRef.current.getBoundingClientRect()
|
||||||
|
const triggerRect = wrapperRef.current.getBoundingClientRect()
|
||||||
|
const doc = tooltipRef.current.ownerDocument
|
||||||
|
const viewportWidth = doc.defaultView?.innerWidth ?? globalThis.innerWidth
|
||||||
|
const padding = 8
|
||||||
|
|
||||||
|
// Vertical flip: if tooltip overflows the top, switch to bottom
|
||||||
|
if (!effectiveBottom && position.top - tooltipRect.height < 0) {
|
||||||
|
const flippedTop = triggerRect.bottom + 8
|
||||||
|
setEffectiveBottom(true)
|
||||||
|
setPosition({ top: flippedTop, left: position.left })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal clamping (both edges)
|
||||||
|
const desiredLeft = position.left - tooltipRect.width / 2
|
||||||
|
const minLeft = padding
|
||||||
|
const maxLeft = viewportWidth - padding - tooltipRect.width
|
||||||
|
|
||||||
|
if (desiredLeft >= minLeft && desiredLeft <= maxLeft) {
|
||||||
|
setComputedStyle(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft))
|
||||||
|
setComputedStyle({
|
||||||
|
left: clampedLeft,
|
||||||
|
arrowLeft: position.left - clampedLeft,
|
||||||
|
})
|
||||||
|
}, [isVisible, position, effectiveBottom])
|
||||||
|
|
||||||
|
const portalContainer = useMemo(() => {
|
||||||
|
if (getContainer) return getContainer()
|
||||||
|
return wrapperRef.current?.ownerDocument?.body ?? document.body
|
||||||
|
}, [getContainer])
|
||||||
const wrappedChild = isValidElement(children)
|
const wrappedChild = isValidElement(children)
|
||||||
? cloneElement(children, {
|
? cloneElement(children, {
|
||||||
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
|
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
|
||||||
@@ -73,11 +124,14 @@ export const VisualOnlyTooltip = ({
|
|||||||
>
|
>
|
||||||
{wrappedChild}
|
{wrappedChild}
|
||||||
</div>
|
</div>
|
||||||
{tooltipData &&
|
{isVisible &&
|
||||||
|
position &&
|
||||||
|
portalContainer &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
ref={tooltipRef}
|
||||||
className={css({
|
className={css({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
@@ -87,15 +141,15 @@ export const VisualOnlyTooltip = ({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 9999,
|
zIndex: 100001,
|
||||||
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
||||||
'&::after': {
|
'&::after': {
|
||||||
content: '""',
|
content: '""',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: 'var(--tooltip-arrow-left, 50%)',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
border: '4px solid transparent',
|
border: '4px solid transparent',
|
||||||
...(isBottom
|
...(effectiveBottom
|
||||||
? {
|
? {
|
||||||
bottom: '100%',
|
bottom: '100%',
|
||||||
borderBottomColor: 'primaryDark.100',
|
borderBottomColor: 'primaryDark.100',
|
||||||
@@ -107,16 +161,27 @@ export const VisualOnlyTooltip = ({
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
top: `${tooltipData.position.top}px`,
|
top: `${position.top}px`,
|
||||||
left: `${tooltipData.position.left}px`,
|
left: computedStyle
|
||||||
transform: isBottom
|
? `${computedStyle.left}px`
|
||||||
? 'translate(-50%, 0)'
|
: `${position.left}px`,
|
||||||
: 'translate(-50%, -100%)',
|
transform: computedStyle
|
||||||
|
? effectiveBottom
|
||||||
|
? 'translateY(0)'
|
||||||
|
: 'translateY(-100%)'
|
||||||
|
: effectiveBottom
|
||||||
|
? 'translate(-50%, 0)'
|
||||||
|
: 'translate(-50%, -100%)',
|
||||||
|
...(computedStyle
|
||||||
|
? {
|
||||||
|
'--tooltip-arrow-left': `${computedStyle.arrowLeft}px`,
|
||||||
|
}
|
||||||
|
: null),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tooltip}
|
{tooltip}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
portalContainer
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to retrieve the portal container for overlays (menus, tooltips, popovers).
|
||||||
|
* Returns the container from UNSAFE_PortalProvider context (pip-root in PiP, undefined in main window).
|
||||||
|
*/
|
||||||
|
export const useOverlayPortalContainer = () => {
|
||||||
|
const { getContainer } = useUNSAFE_PortalContext()
|
||||||
|
|
||||||
|
return useMemo(() => getContainer?.() ?? undefined, [getContainer])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to retrieve the boundary element for overlay positioning.
|
||||||
|
* Returns the portal container in PiP (for PiP-relative positioning), undefined in main window.
|
||||||
|
*/
|
||||||
|
export const useOverlayBoundaryElement = () => {
|
||||||
|
const portalContainer = useOverlayPortalContainer()
|
||||||
|
return portalContainer
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { proxy } from 'valtio'
|
import { proxy } from 'valtio'
|
||||||
import {
|
import { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
||||||
PanelId,
|
|
||||||
SubPanelId,
|
|
||||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
showHeader: boolean
|
showHeader: boolean
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
isOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roomPiPStore = proxy<State>({
|
||||||
|
isOpen: false,
|
||||||
|
})
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first focusable descendant of `root`.
|
||||||
|
* Works across documents (useful for the PiP window, which has its own
|
||||||
|
* `document`). Pass the result of `ownerDocument.getElementById(...)` to
|
||||||
|
* target an element in a specific document.
|
||||||
|
*/
|
||||||
|
export const findFirstFocusable = (
|
||||||
|
root: HTMLElement | null | undefined
|
||||||
|
): HTMLElement | null =>
|
||||||
|
root?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for the main document. Use `findFirstFocusable` when
|
||||||
|
* working with a non-main document (e.g. the PiP window).
|
||||||
|
*/
|
||||||
export const getFirstControlBarFocusable = (id: string): HTMLElement | null =>
|
export const getFirstControlBarFocusable = (id: string): HTMLElement | null =>
|
||||||
document
|
findFirstFocusable(document.getElementById(id))
|
||||||
.getElementById(id)
|
|
||||||
?.querySelector(
|
|
||||||
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
|
||||||
) ?? null
|
|
||||||
|
|||||||
Reference in New Issue
Block a user