(feat) add pip portal and base layout

mount react content into pip window with styles
This commit is contained in:
Cyril
2026-01-20 11:35:12 +01:00
parent 8c3fb63724
commit 4697cbc875
3 changed files with 221 additions and 0 deletions
@@ -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<HTMLElement | null>(null)
const containerRef = useRef<HTMLElement | null>(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
}
@@ -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 (
<PipContainer>
<PipStage>
<ParticipantTile trackRef={trackRef} disableMetadata />
</PipStage>
<PipControlsBar
showScreenShare={browserSupportsScreenSharing}
/>
</PipContainer>
)
}
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
}) => (
<PipControls>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
{showScreenShare && <ScreenShareToggle />}
<LeaveButton />
</PipControls>
)
@@ -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 (
<DocumentPiPPortal isOpen={isOpen} onClose={close}>
<PipView />
</DocumentPiPPortal>
)
}