mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
✨(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:
committed by
aleb_the_flash
parent
4911a7cda0
commit
e9e4b360a0
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+2
@@ -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 />
|
||||
|
||||
+22
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user