From e9e4b360a0d1e286ec2c6c2339bec3cb1fb86ef9 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 19 May 2026 19:22:58 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20introduce=20basic=20docum?= =?UTF-8?q?ent=20picture-in-picture=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + .../pip/components/PictureInPicturePortal.tsx | 51 +++++++++ .../features/pip/hooks/usePictureInPicture.ts | 104 ++++++++++++++++++ src/frontend/src/features/pip/utils.ts | 2 + .../features/rooms/components/Conference.tsx | 5 + .../controls/Options/OptionsMenuItems.tsx | 2 + .../Options/PictureInPictureMenuItem.tsx | 22 ++++ src/frontend/src/locales/de/rooms.json | 4 + src/frontend/src/locales/en/rooms.json | 4 + src/frontend/src/locales/fr/rooms.json | 4 + src/frontend/src/locales/nl/rooms.json | 4 + .../src/stores/documentPictureInPicture.ts | 9 ++ 12 files changed, 212 insertions(+) create mode 100644 src/frontend/src/features/pip/components/PictureInPicturePortal.tsx create mode 100644 src/frontend/src/features/pip/hooks/usePictureInPicture.ts create mode 100644 src/frontend/src/features/pip/utils.ts create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem.tsx create mode 100644 src/frontend/src/stores/documentPictureInPicture.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4326c0ff..a943fc3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/src/features/pip/components/PictureInPicturePortal.tsx b/src/frontend/src/features/pip/components/PictureInPicturePortal.tsx new file mode 100644 index 00000000..00cb20b8 --- /dev/null +++ b/src/frontend/src/features/pip/components/PictureInPicturePortal.tsx @@ -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. + */ + container}> + {children} + , + container + ) +} + +export const PictureInPicturePortal = ({ + children, +}: { + children: React.ReactNode +}): React.ReactNode => { + const { isSupported } = usePictureInPicture() + + if (!isSupported) return null + + return {children} +} diff --git a/src/frontend/src/features/pip/hooks/usePictureInPicture.ts b/src/frontend/src/features/pip/hooks/usePictureInPicture.ts new file mode 100644 index 00000000..6842d0de --- /dev/null +++ b/src/frontend/src/features/pip/hooks/usePictureInPicture.ts @@ -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, + } +} diff --git a/src/frontend/src/features/pip/utils.ts b/src/frontend/src/features/pip/utils.ts new file mode 100644 index 00000000..b333fedb --- /dev/null +++ b/src/frontend/src/features/pip/utils.ts @@ -0,0 +1,2 @@ +export const IS_PIP_SUPPORTED = + typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index b308d9ff..fe7d9090 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -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 })} /> + + + diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx index 37e87854..50439296 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx @@ -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 = () => { }} > + diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem.tsx new file mode 100644 index 00000000..844e8dc3 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem.tsx @@ -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 ( + + + {t(`pictureInPicture.${isOpen ? 'exit' : 'enter'}`)} + + ) +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index f9c81637..fba86077 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -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" diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index af3b1947..8ed94218 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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" diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 5421a207..4a7b8cdc 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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" diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 3579b2ab..491bc2ba 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -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" diff --git a/src/frontend/src/stores/documentPictureInPicture.ts b/src/frontend/src/stores/documentPictureInPicture.ts new file mode 100644 index 00000000..c8c617e7 --- /dev/null +++ b/src/frontend/src/stores/documentPictureInPicture.ts @@ -0,0 +1,9 @@ +import { proxy } from 'valtio' + +type State = { + window: Window | null +} + +export const documentPictureInPictureStore = proxy({ + window: null, +})