Compare commits

...

44 Commits

Author SHA1 Message Date
lebaudantoine 135ce78609 remove the support menu section, are they both opening / interacting in the main window 2026-05-07 12:51:36 +02:00
lebaudantoine 7f23cf1735 wip remove subtitle options as they're not rendered in pip 2026-05-07 12:50:50 +02:00
lebaudantoine 1936c692b2 revert change on the menu primitives 2026-05-07 11:37:06 +02:00
Cyril 435990f589 ♻️(frontend) fix VisualOnlyTooltip clipping in PiP
Flip when top overflows; clamp left/right so PiP tooltips stay visible.
2026-04-29 14:38:00 +02:00
Cyril 1e872e4c3c ♻️(frontend) address Sonar warnings in PiP and side panel
Replace nested ternary logic, use globalThis instead of window, and switch side-panel enum re-exports to export-from for clearer and safer patterns.

Made-with: Cursor
2026-04-29 14:28:17 +02:00
Cyril 7e9b3a6290 ♻️(frontend) normalize PiP component patterns
Extract OverflowItems, replace `any`, convert function declarations to const arrows.
2026-04-29 14:13:24 +02:00
Cyril 1080407bb3 ♻️(frontend) harden PiP notifications stacking and dedupe
Group banner and toasts in one stack, cap visibility, fix chat dedup.
2026-04-29 07:21:42 +02:00
Cyril d7fb31ef2c (frontend) show room notifications inside the PiP window
Render the shared toast queue in PiP.
2026-04-27 11:12:54 +02:00
Cyril c8ae87b082 (frontend) restore toolbar shortcuts inside PiP window
Forward PiP keydown events and register PiP-specific handlers.
2026-04-24 14:26:58 +02:00
Cyril 636d298c18 🐛(frontend) close native PiP window when leaving the room
Force-close PiP on unmount and reset PiP state so next room starts clean.
2026-04-24 13:51:04 +02:00
Cyril f0c5a8c241 🐛(frontend) fix PiP tile corner clipping and unify 4px radius
Set a single clipping owner per PiP layout and use a consistent 4px radius.
2026-04-24 13:36:15 +02:00
Cyril 946da58fb2 ♻️(frontend) PiP options menu and reactions toolbar refactor
PiP-native options popover; split reactions, keyboard nav, and pagination.
2026-04-24 13:06:44 +02:00
Cyril 96edb3725a ️(frontend) PiP accessibility and cross-document focus foundation
Cross-doc focus helpers, Escape stack, lang/title sync, landmarks, SR announce.
2026-04-24 09:30:34 +02:00
Cyril 204d40e0ca 🌐(i18n) add PiP accessibility strings to rooms
Labels for PiP window, stage, toolbar, reactions, and SR open/close.
2026-04-24 09:29:59 +02:00
Cyril c16f4ca846 🎨(frontend) tune PiP window default dimensions
Start PiP in a taller shape closer to a typical video call layout.
2026-04-23 15:05:11 +02:00
Cyril 5362f37883 (frontend) add adaptive PiP stage with focus and grid layouts
Render all participants with a focus mode and animated adaptive grid.
2026-04-23 15:04:59 +02:00
Cyril bac7261fff ♻️(frontend) share a PiP-aware element size hook
Reuse a single ResizeObserver hook in the PiP control bar.
2026-04-22 18:00:37 +02:00
Cyril 40da9c0bf9 (frontend) add PiP reactions toolbar and isolated state
PiP-only reactions strip, store flag, and overflow menu wired to PiP state.
2026-04-10 09:24:48 +02:00
Cyril 284c1ccc8a 🐛(frontend) fix pip controls responsive overflow in realtime
Use PiP document ResizeObserver so controls hide into "..." while resizing.
2026-04-08 10:59:13 +02:00
Cyril 69c9559843 🐛(frontend) align pip layout with google meet
Show a main tile with a bottom-right thumbnail in PiP, like Google Meet.
2026-04-08 10:19:25 +02:00
Cyril 07ed4d701c ♻️(frontend) decouple pip state and simplify pip options
Prevent PiP actions from affecting main panel state and reduce PiP-only noise.
2026-04-08 09:52:08 +02:00
Cyril a40c76bc4d (perf) hide main layout when pip is open
avoid rendering main video tiles while pip is active
2026-04-08 09:36:40 +02:00
Cyril e9684e0c01 ♻️(refactor) move pip feature into dedicated module
group pip components and hooks under features/pip for isolation
2026-04-08 09:34:18 +02:00
Cyril 1c5b6b1e1f (feat) move pip state to valtio store
replace context provider with shared store for pip ui state
2026-04-08 09:34:17 +02:00
Cyril e38d0861b1 🐛(frontend) clamp pip tooltips on right edge
prevent pip tooltips from overflowing the right side of the window
2026-04-08 09:32:50 +02:00
Cyril 0ab2e2f18f fixup! 🌐(i18n) add PiP labels for each supported language 2026-04-08 09:32:49 +02:00
Cyril 99dcd34482 fixup! (feat) connect PiP to room and options menu 2026-04-08 09:32:49 +02:00
Cyril 2bdd8ddbaf fixup! 🐛(fix) add react-aria overlays type shim 2026-04-08 09:30:49 +02:00
Cyril 94488f31cf (feat) connect PiP to room and options menu
wire room-level PiP state to the options menu
2026-04-08 09:30:49 +02:00
Cyril e49355985a 💄(style) align pip layout with room styling
match pip background and spacing to
2026-04-08 09:27:29 +02:00
Cyril 8e3cd3f9a3 💄(style) adjust tooltip positioning and fix ts error
raise tooltip offset and set arrowBoundaryOffset
2026-04-08 09:27:29 +02:00
Cyril 742c1b467b 🐛(fix) add react-aria overlays type shim
fix missing useUNSAFE_PortalContext export typing
2026-04-08 09:27:28 +02:00
Cyril 37b5dba12f (feat) add pip lateral menu component
add pip chevron menu
2026-04-08 09:27:28 +02:00
Cyril 6912d4115d 📝(docs) add pip component documentation
add jsdoc comments to pip components and hooks
2026-04-08 09:27:28 +02:00
Cyril d1a3052f28 🐛(fix) close pip menu on item action
close options menu when selecting transcription or
2026-04-08 09:27:27 +02:00
Cyril fcfcb3eff3 ♻️(refactor) extract pip control bar component
centralize pip controls in reusable component
2026-04-08 09:27:27 +02:00
Cyril 5863b4495a ♻️(feat) extract pip options menu component
keep pip options layout reusable and isolated
2026-04-08 09:27:26 +02:00
Cyril cf37d636db 💄(fix) align overlays in pip
route menus/tooltips to the pip document
2026-04-08 09:27:26 +02:00
Cyril 3fd265f291 (refactor) stabilize pip layout and controls
avoid layout jumps and prepare for more buttons
2026-04-08 09:27:26 +02:00
Cyril 2c30535235 🐛(fix) rebuild pip root on reopen
prevent black screen after closing and reopening pip
2026-04-08 09:27:25 +02:00
Cyril cea373a00f 🌐(i18n) add PiP labels for each supported language
add labels for PiP in each supported language
2026-04-08 09:27:25 +02:00
Cyril 857dccd00b (feat) connect PiP to room and options menu
connecting pip and room
2026-04-08 09:27:24 +02:00
Cyril f0f6c4bf56 (feat) add pip portal and base layout
mount react content into pip window with styles
2026-04-08 09:27:24 +02:00
Cyril 85e13f7e44 (feat) add document pip hook
utility hook to cleanly open/close the pip window
2026-04-08 09:27:19 +02:00
50 changed files with 2588 additions and 109 deletions
+2
View File
@@ -180,6 +180,8 @@ and this project adheres to
- ✨(backend) monitor throttling rate failure through sentry #964
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
### Changed
+18
View File
@@ -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 { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -16,6 +16,10 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
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 = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
@@ -24,12 +28,23 @@ export const MainNotificationToast = () => {
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(() => {
const handleChatMessage = (
chatMessage: ChatMessage,
participant?: Participant | undefined
) => {
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)
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 { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text'
@@ -6,7 +5,7 @@ import { Button, Div } from '@/primitives'
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { type SidePanelStore, useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects'
@@ -144,7 +143,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
export const SidePanel = ({ store }: { store?: SidePanelStore }) => {
const {
activePanelId,
isParticipantsOpen,
@@ -156,7 +155,9 @@ export const SidePanel = () => {
isInfoOpen,
isSubPanelOpen,
activeSubPanelId,
} = useSidePanel()
closePanel,
goBack,
} = useSidePanel(store)
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`)
@@ -166,10 +167,7 @@ export const SidePanel = () => {
<StyledSidePanel
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
onClose={closePanel}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
@@ -177,7 +175,7 @@ export const SidePanel = () => {
isSubmenu={isSubPanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
onBack={goBack}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
@@ -2,11 +2,11 @@ import { RiImageCircleAiFill } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
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 { toggleEffects } = useSidePanel()
const { toggleEffects } = useSidePanel(store)
return (
<MenuItem
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
import { SupportMenuItem } from './SupportMenuItem'
import { TranscriptMenuItem } from './TranscriptMenuItem'
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
import { PictureInPictureMenuItem } from './PictureInPictureMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
@@ -21,6 +22,7 @@ export const OptionsMenuItems = () => {
<TranscriptMenuItem />
<ScreenRecordingMenuItem />
<FullScreenMenuItem />
<PictureInPictureMenuItem />
<EffectsMenuItem />
</MenuSection>
<Separator />
@@ -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 { layoutStore } from '@/stores/layout'
import { PanelId, SubPanelId } from '../types/panel'
export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TOOLS = 'tools',
ADMIN = 'admin',
INFO = 'info',
export { PanelId, SubPanelId } from '../types/panel'
export type SidePanelStore = {
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
}
export enum SubPanelId {
TRANSCRIPT = 'transcript',
SCREEN_RECORDING = 'screenRecording',
}
export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
export const useSidePanel = (store: SidePanelStore = layoutStore) => {
const layoutSnap = useSnapshot(store)
const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isToolsOpen = activePanelId == PanelId.TOOLS
const isAdminOpen = activePanelId == PanelId.ADMIN
const isInfoOpen = activePanelId == PanelId.INFO
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
const isParticipantsOpen = activePanelId === PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId === PanelId.EFFECTS
const isChatOpen = activePanelId === PanelId.CHAT
const isToolsOpen = activePanelId === PanelId.TOOLS
const isAdminOpen = activePanelId === PanelId.ADMIN
const isInfoOpen = activePanelId === PanelId.INFO
const isTranscriptOpen = activeSubPanelId === SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId === SubPanelId.SCREEN_RECORDING
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS
store.activeSubPanelId = SubPanelId.TRANSCRIPT
store.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
store.activeSubPanelId = SubPanelId.SCREEN_RECORDING
store.activePanelId = PanelId.TOOLS
}
const closePanel = () => {
store.activePanelId = null
store.activeSubPanelId = null
}
const goBack = () => {
store.activeSubPanelId = null
}
return {
@@ -82,6 +85,8 @@ export const useSidePanel = () => {
toggleInfo,
openTranscript,
openScreenRecording,
closePanel,
goBack,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
@@ -34,6 +34,8 @@ import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
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 { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
@@ -227,6 +229,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
])
/* eslint-enable react-hooks/exhaustive-deps */
const { isOpen: isPiPOpen } = useRoomPiP()
const shouldRenderMainLayout = !isPiPOpen
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
return (
@@ -248,32 +253,36 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
/>
<IsIdleDisconnectModal />
<RoomContentArea>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
{shouldRenderMainLayout && (
<>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</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>
<ControlBar
@@ -289,6 +298,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
}}
/>
<SidePanel />
<RoomPiP />
</LayoutContextProvider>
)}
<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',
}
+17
View File
@@ -241,6 +241,23 @@
"username": "Deinen Namen aktualisieren",
"effects": "Effekte anwenden",
"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": {
"enter": "Vollbild",
"exit": "Vollbildmodus verlassen"
+17
View File
@@ -241,6 +241,23 @@
"username": "Update Your Name",
"effects": "Backgrounds and Effects",
"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": {
"enter": "Fullscreen",
"exit": "Exit fullscreen mode"
+17
View File
@@ -241,6 +241,23 @@
"username": "Choisir votre nom",
"effects": "Arrière-plans et effets",
"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": {
"enter": "Plein écran",
"exit": "Quitter le mode plein écran"
+17
View File
@@ -241,6 +241,23 @@
"username": "Verander uw naam",
"effects": "Pas effecten toe",
"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": {
"enter": "Volledig scherm",
"exit": "Stop volledig scherm stand"
+28 -2
View File
@@ -1,10 +1,16 @@
import { ReactNode } from 'react'
import { ReactNode, useMemo } from 'react'
import { MenuTrigger } from 'react-aria-components'
import { StyledPopover } from './Popover'
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
*
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
*/
export const Menu = ({
children,
@@ -16,10 +22,30 @@ export const Menu = ({
placement?: 'bottom' | 'top' | 'left' | 'right'
}) => {
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 (
<MenuTrigger>
{trigger}
<StyledPopover placement={placement}>
<StyledPopover
placement={placement}
// shouldFlip={shouldFlip}
// boundaryElement={boundaryElement}
>
<Box size="sm" type="popover" variant={variant}>
{menu}
</Box>
+5 -1
View File
@@ -8,6 +8,7 @@ import {
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { Box } from './Box'
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
export const StyledPopover = styled(RACPopover, {
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>.
* 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 = ({
children,
@@ -82,10 +85,11 @@ export const Popover = ({
withArrow?: boolean
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
const boundaryElement = useOverlayBoundaryElement()
return (
<DialogTrigger>
{trigger}
<StyledPopover>
<StyledPopover boundaryElement={boundaryElement}>
{withArrow && (
<StyledOverlayArrow variant={variant}>
<svg width={12} height={12} viewBox="0 0 12 12">
+32 -8
View File
@@ -6,12 +6,17 @@ import {
type TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { useOverlayPortalContainer } from './useOverlayPortalContainer'
import { VisualOnlyTooltip } from './VisualOnlyTooltip'
export type TooltipWrapperProps = {
tooltip?: string
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)
*
@@ -24,11 +29,25 @@ export const TooltipWrapper = ({
}: {
children: ReactNode
} & TooltipWrapperProps) => {
const portalContainer = useOverlayPortalContainer()
const isExternalDocument =
portalContainer && portalContainer.ownerDocument !== document
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
isExternalDocument ? (
<VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
) : (
<TooltipTrigger
delay={
tooltipType === 'instant'
? INSTANT_TOOLTIP_DELAY_MS
: DELAYED_TOOLTIP_DELAY_MS
}
>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
)
) : (
children
)
@@ -39,6 +58,8 @@ export const TooltipWrapper = ({
*
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
*/
const DEFAULT_TOOLTIP_GAP_PX = 8
const StyledTooltip = styled(RACTooltip, {
base: {
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
@@ -53,11 +74,11 @@ const StyledTooltip = styled(RACTooltip, {
fontSize: 14,
transform: 'translate3d(0, 0, 0)',
'&[data-placement=top]': {
marginBottom: '8px',
marginBottom: `${DEFAULT_TOOLTIP_GAP_PX}px`,
'--origin': 'translateY(4px)',
},
'&[data-placement=bottom]': {
marginTop: '8px',
marginTop: `${DEFAULT_TOOLTIP_GAP_PX}px`,
'--origin': 'translateY(-4px)',
},
'&[data-placement=right]': {
@@ -107,10 +128,13 @@ const TooltipArrow = () => {
const Tooltip = ({
children,
arrowBoundaryOffset,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
}: {
children: ReactNode
} & Partial<Omit<TooltipProps, 'children'>>) => {
return (
<StyledTooltip {...props}>
<StyledTooltip arrowBoundaryOffset={arrowBoundaryOffset ?? 0} {...props}>
<TooltipArrow />
{children}
</StyledTooltip>
@@ -2,11 +2,14 @@ import {
type ReactElement,
cloneElement,
isValidElement,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
export type VisualOnlyTooltipProps = {
children: ReactElement
@@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const [computedStyle, setComputedStyle] = useState<{
left: number
arrowLeft: number
} | null>(null)
const isBottom = tooltipPosition === 'bottom'
const [effectiveBottom, setEffectiveBottom] = useState(
tooltipPosition === 'bottom'
)
const showTooltip = () => {
if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
const preferBottom = tooltipPosition === 'bottom'
setEffectiveBottom(preferBottom)
setPosition({
top: isBottom ? rect.bottom + 8 : rect.top - 8,
top: preferBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2,
})
setIsVisible(true)
@@ -53,9 +66,47 @@ export const VisualOnlyTooltip = ({
const hideTooltip = () => {
setIsVisible(false)
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)
? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
@@ -73,11 +124,14 @@ export const VisualOnlyTooltip = ({
>
{wrappedChild}
</div>
{tooltipData &&
{isVisible &&
position &&
portalContainer &&
createPortal(
<div
aria-hidden="true"
role="presentation"
ref={tooltipRef}
className={css({
position: 'fixed',
padding: '2px 8px',
@@ -87,15 +141,15 @@ export const VisualOnlyTooltip = ({
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
zIndex: 100001,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
left: '50%',
left: 'var(--tooltip-arrow-left, 50%)',
transform: 'translateX(-50%)',
border: '4px solid transparent',
...(isBottom
...(effectiveBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
@@ -107,16 +161,27 @@ export const VisualOnlyTooltip = ({
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
top: `${position.top}px`,
left: computedStyle
? `${computedStyle.left}px`
: `${position.left}px`,
transform: computedStyle
? effectiveBottom
? 'translateY(0)'
: 'translateY(-100%)'
: effectiveBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
...(computedStyle
? {
'--tooltip-arrow-left': `${computedStyle.arrowLeft}px`,
}
: null),
}}
>
{tooltip}
</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 -4
View File
@@ -1,8 +1,5 @@
import { proxy } from 'valtio'
import {
PanelId,
SubPanelId,
} from '@/features/rooms/livekit/hooks/useSidePanel'
import { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
type State = {
showHeader: boolean
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
isOpen: boolean
}
export const roomPiPStore = proxy<State>({
isOpen: false,
})
+19 -5
View File
@@ -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 =>
document
.getElementById(id)
?.querySelector(
'input, select, textarea, button, object, a, area[href], [tabindex]'
) ?? null
findFirstFocusable(document.getElementById(id))