Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6d288574a | |||
| e0e6a205ac | |||
| 9803e17a13 | |||
| 45cf8a425a | |||
| 67dd60cc20 | |||
| 401480f41f | |||
| 68f62aebd9 | |||
| cdba69c607 | |||
| 84b7402404 | |||
| bc61134b28 | |||
| 986f945a20 | |||
| 4fa8998eb2 | |||
| e3ce5677a7 | |||
| 83265698fa | |||
| f02135e902 | |||
| 3634f2b57d | |||
| 56591a3d4c | |||
| 49afe8aa49 | |||
| aa2f8ee4b2 | |||
| 0cf5d2cfe5 | |||
| c866e75265 | |||
| df42a543a2 | |||
| 89cf09f3fd | |||
| e2f06d82a0 |
@@ -12,6 +12,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useDocumentPiP } from '../hooks/useDocumentPiP'
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 that renders children into a Document Picture-in-Picture window.
|
||||
* Handles PiP window lifecycle, style injection, React root management, and uses UNSAFE_PortalProvider
|
||||
* to ensure React Aria overlays render correctly within the PiP window.
|
||||
* Creates a fresh React root on reopen to prevent black screen issues.
|
||||
*/
|
||||
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 [container, setContainer] = useState<HTMLElement | null>(null)
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
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)
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipWindow) return
|
||||
const handleClose = () => {
|
||||
// Reset container so reopening PiP mounts a fresh root.
|
||||
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" because it bypasses react-aria's default portal container.
|
||||
// We need it to target the PiP document; otherwise overlays render in the main window.
|
||||
<UNSAFE_PortalProvider getContainer={() => container}>
|
||||
{children}
|
||||
</UNSAFE_PortalProvider>,
|
||||
container
|
||||
)
|
||||
}, [children, container])
|
||||
|
||||
return portal as unknown as ReactNode
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
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 { ReactionsToggle } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import { SubtitlesToggle } from '@/features/rooms/livekit/components/controls/SubtitlesToggle'
|
||||
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
|
||||
import { OptionsButton } from '@/features/rooms/livekit/components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
|
||||
|
||||
/**
|
||||
* Compact control bar for the Picture-in-Picture window.
|
||||
* Centralizes all PiP controls (devices, reactions, screen share, options, etc.) in one reusable component.
|
||||
*/
|
||||
export const PipControlBar = ({
|
||||
showScreenShare,
|
||||
}: {
|
||||
showScreenShare: boolean
|
||||
}) => (
|
||||
<PipControls>
|
||||
<PipControlsCenter>
|
||||
<AudioDevicesControl hideMenu />
|
||||
<VideoDeviceControl hideMenu />
|
||||
<ReactionsToggle />
|
||||
{showScreenShare && <ScreenShareToggle />}
|
||||
<SubtitlesToggle />
|
||||
<HandToggle />
|
||||
<OptionsButton />
|
||||
<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: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import {
|
||||
isTrackReference,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { useTracks } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { GridLayout } from '@/features/rooms/livekit/components/layout/GridLayout'
|
||||
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
|
||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||
import { PipControlBar } from './PipControlBar'
|
||||
|
||||
const pickTrackForPip = (
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined => {
|
||||
// Prefer screen share when present; otherwise fallback to first available track.
|
||||
const screenShareTrack = tracks
|
||||
.filter(isTrackReference)
|
||||
.find((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
if (screenShareTrack) return screenShareTrack
|
||||
return tracks[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Main view component for the Picture-in-Picture window.
|
||||
* Handles track selection (prioritizes screen share), layout switching (grid for multiple participants),
|
||||
* and renders the control bar and side panel within the PiP window.
|
||||
*/
|
||||
export const PipView = () => {
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: false }
|
||||
)
|
||||
|
||||
const trackRef = pickTrackForPip(tracks)
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const hasMultipleTiles = tracks.length > 1
|
||||
|
||||
if (!trackRef && !hasMultipleTiles) return null
|
||||
|
||||
return (
|
||||
<PipContainer>
|
||||
{/* Keep stage height stable to avoid layout shifting on track changes. */}
|
||||
<PipStage>
|
||||
{hasMultipleTiles ? (
|
||||
<PipGridWrapper>
|
||||
<GridLayout tracks={tracks} style={{ height: '100%' }}>
|
||||
<ParticipantTile disableMetadata />
|
||||
</GridLayout>
|
||||
</PipGridWrapper>
|
||||
) : (
|
||||
<ParticipantTile trackRef={trackRef} disableMetadata />
|
||||
)}
|
||||
</PipStage>
|
||||
{/* Compact control bar for PiP; extend here when adding more actions. */}
|
||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||
{/* Side panel (effects, settings, etc.) opens within PiP window. */}
|
||||
<SidePanel store={pipLayoutStore} />
|
||||
</PipContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const PipContainer = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'minmax(0, 1fr) auto',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
'& .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%',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const PipStage = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
minHeight: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const PipGridWrapper = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
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()
|
||||
|
||||
const portal = DocumentPiPPortal({
|
||||
isOpen,
|
||||
onClose: close,
|
||||
children: <PipView />,
|
||||
})
|
||||
return portal as ReactNode
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { Box, Button } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
|
||||
|
||||
type PipOptionsMenuProps = {
|
||||
wrapperRef: React.RefObject<HTMLDivElement>
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PiP-specific options menu with absolute positioning for correct alignment in PiP window.
|
||||
* Renders locally (unlike standard Menu) and closes automatically on item click or outside click.
|
||||
*/
|
||||
export const PipOptionsMenu = ({
|
||||
wrapperRef,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
label,
|
||||
}: PipOptionsMenuProps) => {
|
||||
// Close menu when a menu item action completes (e.g., transcription, effects, recording).
|
||||
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
|
||||
|
||||
// Don't close if clicking the trigger button
|
||||
if (wrapper.querySelector('button')?.contains(target)) return
|
||||
|
||||
// Close if clicking a menu item (action will have fired)
|
||||
if (target.closest('[role="menuitem"]')) {
|
||||
// Use requestAnimationFrame to ensure action completes first, without visible delay
|
||||
requestAnimationFrame(() => {
|
||||
setIsOpen(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
doc.addEventListener('click', handleMenuItemClick, true)
|
||||
return () => {
|
||||
doc.removeEventListener('click', handleMenuItemClick, true)
|
||||
}
|
||||
}, [isOpen, setIsOpen, wrapperRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={label}
|
||||
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,
|
||||
})}
|
||||
>
|
||||
<Box size="sm" type="popover" variant="dark">
|
||||
<PipOptionsMenuItems />
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { SettingsMenuItem } from '@/features/rooms/livekit/components/controls/Options/SettingsMenuItem'
|
||||
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'
|
||||
|
||||
/**
|
||||
* PiP options menu items: excludes transcript, screen recording, and full screen
|
||||
* (those features are not relevant in the PiP window context).
|
||||
*/
|
||||
export const PipOptionsMenuItems = () => (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '150px',
|
||||
width: '300px',
|
||||
}}
|
||||
>
|
||||
<MenuSection>
|
||||
<PictureInPictureMenuItem />
|
||||
<EffectsMenuItem store={pipLayoutStore} />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
<MenuSection>
|
||||
<SupportMenuItem />
|
||||
<FeedbackMenuItem />
|
||||
<SettingsMenuItem />
|
||||
</MenuSection>
|
||||
</RACMenu>
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
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 = 480,
|
||||
height = 270,
|
||||
}: {
|
||||
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 window === 'undefined') return false
|
||||
return 'documentPictureInPicture' in window
|
||||
})
|
||||
|
||||
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 = (window 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])
|
||||
|
||||
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,29 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { roomPiPStore } from '@/stores/roomPiP'
|
||||
|
||||
export const useRoomPiP = () => {
|
||||
const { isOpen } = useSnapshot(roomPiPStore)
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && 'documentPictureInPicture' in window
|
||||
|
||||
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,17 @@
|
||||
import { proxy } from 'valtio'
|
||||
import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
||||
|
||||
type PipLayoutState = {
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
})
|
||||
@@ -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'
|
||||
@@ -135,7 +134,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,
|
||||
@@ -147,24 +146,23 @@ export const SidePanel = () => {
|
||||
isInfoOpen,
|
||||
isSubPanelOpen,
|
||||
activeSubPanelId,
|
||||
} = useSidePanel()
|
||||
closePanel,
|
||||
goBack,
|
||||
} = useSidePanel(store)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
ariaLabel={t('ariaLabel')}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
onClose={closePanel}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
onBack={goBack}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -2,9 +2,27 @@ import { useTranslation } from 'react-i18next'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { OptionsMenuItems } from './OptionsMenuItems'
|
||||
import { useOverlayPortalContainer } from '@/primitives/useOverlayPortalContainer'
|
||||
import { useRef, useState } from 'react'
|
||||
import { PipOptionsMenu } from '@/features/pip/components/controls/PipOptionsMenu'
|
||||
|
||||
export const OptionsButton = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const portalContainer = useOverlayPortalContainer()
|
||||
const isInPiP = portalContainer && portalContainer.ownerDocument !== document
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
if (isInPiP) {
|
||||
return (
|
||||
<PipOptionsMenu
|
||||
wrapperRef={wrapperRef}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
label={t('options.buttonLabel')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
|
||||
+2
@@ -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 />
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiPictureInPicture2Line } from '@remixicon/react'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
|
||||
export const PictureInPictureMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isSupported, isOpen, toggle } = useRoomPiP()
|
||||
|
||||
// Hide the entry when the browser doesn't support Document PiP.
|
||||
if (!isSupported) return null
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={toggle}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiPictureInPicture2Line size={20} />
|
||||
{isOpen ? t('pictureInPicture.exit') : t('pictureInPicture.enter')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +1,77 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { 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 }
|
||||
|
||||
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,
|
||||
|
||||
@@ -38,9 +38,16 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
import { GridLayout } from '../components/layout/GridLayout'
|
||||
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'
|
||||
|
||||
const SIDE_PANEL_WIDTH = '358px'
|
||||
const CONTROL_BAR_HEIGHT = '80px'
|
||||
const SIDE_PANEL_OFFSET = '16px'
|
||||
const SIDE_PANEL_GAP = '3rem'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
cva({
|
||||
@@ -243,8 +250,16 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
|
||||
const { isSidePanelOpen } = useSidePanel()
|
||||
const { areSubtitlesOpen } = useSubtitles()
|
||||
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||
const shouldRenderMainLayout = !isPiPOpen
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
const layoutInsetVars = {
|
||||
'--lk-side-panel-width': SIDE_PANEL_WIDTH,
|
||||
'--lk-controlbar-height': CONTROL_BAR_HEIGHT,
|
||||
'--lk-side-panel-offset': SIDE_PANEL_OFFSET,
|
||||
'--lk-side-panel-gap': SIDE_PANEL_GAP,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -259,75 +274,78 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
value={layoutContext}
|
||||
// onPinChange={handleFocusStateChange}
|
||||
>
|
||||
<ScreenShareErrorModal
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: isSidePanelOpen
|
||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{!focusTrack ? (
|
||||
<ScreenShareErrorModal
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...layoutInsetVars,
|
||||
inset: isSidePanelOpen
|
||||
? `var(--lk-grid-gap) calc(var(--lk-side-panel-width) + var(--lk-side-panel-gap)) calc(var(--lk-controlbar-height) + var(--lk-grid-gap)) var(--lk-side-panel-offset)`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--lk-controlbar-height) + var(--lk-grid-gap))`,
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{shouldRenderMainLayout && (
|
||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<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',
|
||||
}}
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutWrapper>
|
||||
<Subtitles />
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
console.error(e)
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() ==
|
||||
'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
</LayoutWrapper>
|
||||
)}
|
||||
<Subtitles />
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
console.error(e)
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() ==
|
||||
'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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',
|
||||
}
|
||||
@@ -234,6 +234,10 @@
|
||||
"username": "Ihren Namen aktualisieren",
|
||||
"effects": "Effekte anwenden",
|
||||
"switchCamera": "Kamera wechseln",
|
||||
"pictureInPicture": {
|
||||
"enter": "Bild-im-Bild",
|
||||
"exit": "Bild-im-Bild schließen"
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Vollbild",
|
||||
"exit": "Vollbildmodus verlassen"
|
||||
|
||||
@@ -234,6 +234,10 @@
|
||||
"username": "Update Your Name",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"switchCamera": "Switch camera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Picture-in-picture",
|
||||
"exit": "Close picture-in-picture"
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
"exit": "Exit fullscreen mode"
|
||||
|
||||
@@ -234,6 +234,10 @@
|
||||
"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"
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
"exit": "Quitter le mode plein écran"
|
||||
|
||||
@@ -234,6 +234,10 @@
|
||||
"username": "Verander uw naam",
|
||||
"effects": "Pas effecten toe",
|
||||
"switchCamera": "Selecteer camera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Beeld-in-beeld",
|
||||
"exit": "Beeld-in-beeld sluiten"
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Volledig scherm",
|
||||
"exit": "Stop volledig scherm stand"
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
import { MenuTrigger } from 'react-aria-components'
|
||||
import { 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 ?? defaultPlacement}
|
||||
shouldFlip={shouldFlip}
|
||||
boundaryElement={boundaryElement}
|
||||
>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
{menu}
|
||||
</Box>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,11 +35,17 @@ 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'
|
||||
|
||||
@@ -53,9 +62,28 @@ export const VisualOnlyTooltip = ({
|
||||
const hideTooltip = () => {
|
||||
setIsVisible(false)
|
||||
setPosition(null)
|
||||
setComputedStyle(null)
|
||||
}
|
||||
|
||||
const tooltipData = isVisible && position ? { isVisible, position } : null
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !isVisible || !position) return
|
||||
const tooltipWidth = tooltipRef.current.getBoundingClientRect().width
|
||||
const doc = tooltipRef.current.ownerDocument
|
||||
const viewportWidth = doc.defaultView?.innerWidth ?? window.innerWidth
|
||||
const padding = 8
|
||||
const desiredLeft = position.left - tooltipWidth / 2
|
||||
const maxLeft = viewportWidth - padding - tooltipWidth
|
||||
if (desiredLeft <= maxLeft) {
|
||||
setComputedStyle(null)
|
||||
return
|
||||
}
|
||||
setComputedStyle({ left: maxLeft, arrowLeft: position.left - maxLeft })
|
||||
}, [isVisible, position])
|
||||
|
||||
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 +101,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,12 +118,12 @@ 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
|
||||
@@ -107,16 +138,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
|
||||
? isBottom
|
||||
? 'translateY(0)'
|
||||
: 'translateY(-100%)'
|
||||
: isBottom
|
||||
? '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
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { proxy } from 'valtio'
|
||||
import {
|
||||
PanelId,
|
||||
SubPanelId,
|
||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
} from '@/features/rooms/livekit/types/panel'
|
||||
|
||||
type State = {
|
||||
showHeader: boolean
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const roomPiPStore = proxy<State>({
|
||||
isOpen: false,
|
||||
})
|
||||
Reference in New Issue
Block a user