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 (
+
+ )
+}
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,
+})