(frontend) introduce basic document picture-in-picture hook

Add a hook that manages the logic needed to open a document
picture-in-picture window, stores a ref to the window in a global
store, and—once ready—mounts a portal to duplicate content into
the PiP document's container.

Mounting the PiP content via a portal lets us share the same React
tree as the main app, and therefore share application state. This
comes with some trade-offs, particularly around components that
rely on the DOM hierarchy: react-aria popovers and overlays may
not behave correctly across documents.

The logic is kept to a minimum here. This first commit does nothing
more than open a window containing a loading spinner. The topic is
new to us, so plenty is likely to be refined in follow-ups.

Co-authored-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
lebaudantoine
2026-05-19 19:22:58 +02:00
committed by aleb_the_flash
parent 4911a7cda0
commit e9e4b360a0
12 changed files with 212 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to
- ✨(frontend) add synchronizer for room metadata updates
- ✨(frontend) make reaction toolbar responsive on small viewports
- ✨(frontend) enable reactions on mobile devices
- ✨(frontend) introduce picture-in-picture meeting
### Changed
@@ -0,0 +1,51 @@
import { usePictureInPicture } from '../hooks/usePictureInPicture'
import { createPortal } from 'react-dom'
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useSnapshot } from 'valtio'
import { useEffect, useMemo } from 'react'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
}, [pipStoreSnap.window])
useEffect(() => {
return () => {
documentPictureInPictureStore.window?.close()
}
}, [])
if (!container) return null
return createPortal(
/**
* UNSAFE_PortalProvider is marked unsafe because overlays are normally
* portalled to `document.body` to avoid clipping, stacking, and accessibility
* issues. Redirecting them to another container can break those guarantees.
*
* We accept that risk here because the PiP window is a separate document with
* its own `body`. Rendering overlays into the main document would make them
* invisible in PiP, so we intentionally portal them into the PiP document root
* instead.
*/
<UNSAFE_PortalProvider getContainer={() => container}>
{children}
</UNSAFE_PortalProvider>,
container
)
}
export const PictureInPicturePortal = ({
children,
}: {
children: React.ReactNode
}): React.ReactNode => {
const { isSupported } = usePictureInPicture()
if (!isSupported) return null
return <InternalPortal>{children}</InternalPortal>
}
@@ -0,0 +1,104 @@
import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react'
import { IS_PIP_SUPPORTED } from '@/features/pip/utils'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
export const usePictureInPicture = () => {
const { window: pipWindowRef } = useSnapshot(documentPictureInPictureStore)
const isOpen = useMemo(() => {
return !!pipWindowRef && !pipWindowRef.closed
}, [pipWindowRef])
const syncStyles = useCallback((pipWindow: Window) => {
document.head
.querySelectorAll('link[rel="stylesheet"], style')
.forEach((node) => {
pipWindow.document.head.appendChild(node.cloneNode(true))
})
pipWindow.document.documentElement.className =
document.documentElement.className
pipWindow.document.documentElement.style.cssText =
document.documentElement.style.cssText
const theme = document.documentElement.dataset.lkTheme
if (theme) {
pipWindow.document.documentElement.dataset.lkTheme = theme
}
}, [])
const initializePortalContainer = useCallback((pipWindow: Window) => {
const existing = pipWindow.document.getElementById('root')
if (existing) return existing
const newContainer = pipWindow.document.createElement('div')
newContainer.id = 'root'
newContainer.style.width = '100%'
newContainer.style.height = '100%'
pipWindow.document.body.appendChild(newContainer)
}, [])
const initializeTitleAndLanguage = useCallback(
(pipWindow: Window, title: string) => {
const parentLang = document?.documentElement.lang || 'en'
pipWindow.document.documentElement.setAttribute('lang', parentLang)
pipWindow.document.title = title
},
[]
)
const open = useCallback(
async (width = 400, height = 480) => {
if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null
try {
const pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({
width,
height,
})
initializeTitleAndLanguage(pipWindow, 'wip')
initializePortalContainer(pipWindow)
syncStyles(pipWindow)
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
documentPictureInPictureStore.window = null
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
pipWindow.addEventListener('beforeunload', () => cleanUp(), {
once: true,
})
documentPictureInPictureStore.window = ref(pipWindow)
} 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
}
},
[initializePortalContainer, initializeTitleAndLanguage, isOpen, syncStyles]
)
const close = useCallback(() => {
documentPictureInPictureStore.window?.close()
documentPictureInPictureStore.window = null
}, [])
const toggle = useCallback(async () => {
if (isOpen) close()
else await open()
}, [isOpen, close, open])
return {
isSupported: IS_PIP_SUPPORTED,
isOpen,
open,
close,
toggle,
}
}
+2
View File
@@ -0,0 +1,2 @@
export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
@@ -32,6 +32,8 @@ import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPicturePortal } from '@/features/pip/components/PictureInPicturePortal'
import { Spinner } from '@/primitives/Spinner'
export const Conference = ({
roomId,
@@ -291,6 +293,9 @@ export const Conference = ({
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPicturePortal>
<Spinner />
</PictureInPicturePortal>
</LiveKitRoom>
</Screen>
</QueryAware>
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
import { SupportMenuItem } from './SupportMenuItem'
import { TranscriptMenuItem } from './TranscriptMenuItem'
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
@@ -18,6 +19,7 @@ export const OptionsMenuItems = () => {
}}
>
<MenuSection>
<PictureInPictureMenuItem />
<TranscriptMenuItem />
<ScreenRecordingMenuItem />
<FullScreenMenuItem />
@@ -0,0 +1,22 @@
import { RiPictureInPicture2Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
export const PictureInPictureMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggle, isOpen, isSupported } = usePictureInPicture()
if (!isSupported) return null
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={toggle}
>
<RiPictureInPicture2Line size={20} />
{t(`pictureInPicture.${isOpen ? 'exit' : 'enter'}`)}
</MenuItem>
)
}
+4
View File
@@ -241,6 +241,10 @@
"username": "Deinen 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"
+4
View File
@@ -241,6 +241,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"
+4
View File
@@ -241,6 +241,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"
+4
View File
@@ -241,6 +241,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"
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
window: Window | null
}
export const documentPictureInPictureStore = proxy<State>({
window: null,
})