From 4697cbc8758b475237e338a66287e1d456e9800f Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 20 Jan 2026 11:35:12 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(feat)=20add=20pip=20portal=20and=20ba?= =?UTF-8?q?se=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mount react content into pip window with styles --- .../livekit/components/DocumentPiPPortal.tsx | 103 +++++++++++++++++ .../rooms/livekit/components/PipView.tsx | 104 ++++++++++++++++++ .../rooms/livekit/components/RoomPiP.tsx | 14 +++ 3 files changed, 221 insertions(+) create mode 100644 src/frontend/src/features/rooms/livekit/components/DocumentPiPPortal.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/PipView.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/RoomPiP.tsx diff --git a/src/frontend/src/features/rooms/livekit/components/DocumentPiPPortal.tsx b/src/frontend/src/features/rooms/livekit/components/DocumentPiPPortal.tsx new file mode 100644 index 00000000..f40d604e --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/DocumentPiPPortal.tsx @@ -0,0 +1,103 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useDocumentPiP } from '../hooks/useDocumentPiP' + +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; } + * { box-sizing: border-box; } + ` + target.head.appendChild(style) +} + +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) + }) +} + +export const DocumentPiPPortal = ({ + isOpen, + width, + height, + children, + onClose, +}: { + isOpen: boolean + width?: number + height?: number + children: React.ReactNode + onClose?: () => void +}) => { + const { openPiP, closePiP, pipWindow, isSupported } = useDocumentPiP({ + width, + height, + }) + const [container, setContainer] = useState(null) + const containerRef = useRef(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) + const existingContainer = containerRef.current + if (!existingContainer || existingContainer.ownerDocument !== doc) { + const nextContainer = doc.createElement('div') + nextContainer.id = 'pip-root' + 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 = () => { + 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(children, container) + }, [children, container]) + + return portal +} + diff --git a/src/frontend/src/features/rooms/livekit/components/PipView.tsx b/src/frontend/src/features/rooms/livekit/components/PipView.tsx new file mode 100644 index 00000000..23213ce4 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/PipView.tsx @@ -0,0 +1,104 @@ +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 { AudioDevicesControl } from './controls/Device/AudioDevicesControl' +import { VideoDeviceControl } from './controls/Device/VideoDeviceControl' +import { ScreenShareToggle } from './controls/ScreenShareToggle' +import { LeaveButton } from './controls/LeaveButton' +import { ParticipantTile } from './ParticipantTile' + +const pickTrackForPip = ( + tracks: TrackReferenceOrPlaceholder[] +): TrackReferenceOrPlaceholder | undefined => { + const screenShareTrack = tracks + .filter(isTrackReference) + .find((track) => track.publication.source === Track.Source.ScreenShare) + + if (screenShareTrack) return screenShareTrack + return tracks[0] +} + +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() + + if (!trackRef) return null + + return ( + + + + + + + ) +} + +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', + }, + }, +}) + +const PipStage = styled('div', { + base: { + position: 'relative', + minHeight: 0, + }, +}) + +const PipControls = styled('div', { + base: { + flex: '0 0 auto', + display: 'flex', + justifyContent: 'center', + gap: '0.5rem', + padding: '0.5rem', + backgroundColor: 'primaryDark.100', + borderTop: '1px solid', + borderColor: 'primaryDark.200', + }, +}) + +const PipControlsBar = ({ + showScreenShare, +}: { + showScreenShare: boolean +}) => ( + + + + {showScreenShare && } + + +) + diff --git a/src/frontend/src/features/rooms/livekit/components/RoomPiP.tsx b/src/frontend/src/features/rooms/livekit/components/RoomPiP.tsx new file mode 100644 index 00000000..da233d0b --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/RoomPiP.tsx @@ -0,0 +1,14 @@ +import { DocumentPiPPortal } from './DocumentPiPPortal' +import { PipView } from './PipView' +import { useRoomPiP } from '../hooks/useRoomPiP' + +export const RoomPiP = () => { + const { isOpen, close } = useRoomPiP() + + return ( + + + + ) +} +