(frontend) free the meeting layout while a screen share is popped out

Unpin for grid view; keep screen share out until window closes
This commit is contained in:
Ovgodd
2026-09-23 12:28:53 +02:00
parent 5982f5fa7f
commit ca67e12bf5
14 changed files with 289 additions and 150 deletions
+2 -1
View File
@@ -13,6 +13,7 @@ and this project adheres to
- ✨(backend) make the LiveKit default video codec configurable
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
- ✨(frontend) open screen share in a separate window #1734
### Changed
@@ -32,7 +33,7 @@ and this project adheres to
### Fixed
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) probe liveness on **lbheartbeat** and readiness on **heartbeat**
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
@@ -12,6 +12,10 @@ import {
import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import {
closeScreenSharePopout,
screenSharePopoutStore,
} from '@/stores/screenSharePopout'
import { useEffect, useRef } from 'react'
export const StageLayout = () => {
@@ -31,8 +35,26 @@ export const StageLayout = () => {
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const { pinnedTrackRef } = useSnapshot(layoutStore)
const { entry: popoutEntry } = useSnapshot(screenSharePopoutStore)
const detachedSid = popoutEntry?.trackSid
const carouselTracks = tracks.filter(
// The popped-out share stays mounted below, but out of the grid and the
// carousel. It comes back with the other tracks when its window closes.
const visibleTracks = detachedSid
? tracks.filter(
(track) =>
!isTrackReference(track) || track.publication.trackSid !== detachedSid
)
: tracks
const detachedTrack = detachedSid
? tracks.find(
(track) =>
isTrackReference(track) && track.publication.trackSid === detachedSid
)
: undefined
const carouselTracks = visibleTracks.filter(
(track) => !isEqualTrackRef(track, pinnedTrackRef)
)
@@ -85,11 +107,36 @@ export const StageLayout = () => {
])
/* eslint-enable react-hooks/exhaustive-deps */
const screenShareKey = screenShareTracks
.map((track) => track.publication.trackSid)
.join()
// The popped-out tile is kept mounted below, so nothing else notices when
// the share stops. Close the window here instead.
useEffect(() => {
const entry = screenSharePopoutStore.entry
if (!entry) return
const alive = screenShareTracks.some(
(track) => track.publication.trackSid === entry.trackSid
)
if (!alive) closeScreenSharePopout({ restorePin: false })
// screenShareKey is the sid list; the array itself is new every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenShareKey])
return (
<>
{/* Out of the layout, but still mounted: this subtree is what renders
into the separate window. hidden also takes it off the a11y tree
and out of the tab order. */}
{detachedTrack && (
<div hidden>
<ParticipantTile trackRef={detachedTrack} />
</div>
)}
{!pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<GridLayout tracks={visibleTracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
@@ -156,6 +156,7 @@ export const ParticipantTile: (
<ScreenShareZoomableVideo
tileRef={tileRef}
participantName={participantName}
trackSid={trackReference.publication.trackSid}
windowName={`meet-screen-share-${trackReference.publication.trackSid || trackReference.participant.identity}`}
>
{videoTrack}
@@ -1,45 +0,0 @@
import { useId } from 'react'
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
// Fills the tile while the video is in the other window. Bring-back
// does not stop the share.
export const ScreenSharePopoutPlaceholder = ({
onReturn,
}: {
onReturn: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const titleId = useId()
return (
<div
role="region"
aria-labelledby={titleId}
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
gap: '0.5rem',
padding: '1.5rem',
textAlign: 'center',
color: 'white',
backgroundColor: 'primaryDark.50',
})}
>
<Text as="h2" id={titleId} variant="body">
{t('placeholderTitle')}
</Text>
<Text variant="sm" style={{ maxWidth: '20rem' }}>
{t('placeholderDescription')}
</Text>
<Button variant="primaryTextDark" onPress={onReturn}>
{t('placeholderBringBack')}
</Button>
</div>
)
}
@@ -13,12 +13,17 @@ import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
import { useScreenSharePopout } from '../hooks/useScreenSharePopout'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
import { ScreenSharePopoutPlaceholder } from './ScreenSharePopoutPlaceholder'
import { ScreenSharePopoutPortal } from './ScreenSharePopoutPortal'
import {
saveScreenShareZoom,
takePopoutButtonFocus,
takeScreenShareZoom,
} from '@/stores/screenSharePopout'
interface ScreenShareZoomableVideoProps {
tileRef: React.RefObject<HTMLDivElement | null>
participantName: string
trackSid: string
windowName: string
children: ReactNode
}
@@ -44,6 +49,7 @@ const popoutChromeClassName = css({
export const ScreenShareZoomableVideo = ({
tileRef,
participantName,
trackSid,
windowName,
children,
}: ScreenShareZoomableVideoProps) => {
@@ -60,11 +66,19 @@ export const ScreenShareZoomableVideo = ({
)
const popout = useScreenSharePopout({
trackSid,
windowName,
title: t('separateWindowTitle', { name: participantName }),
getVideoElement,
})
// Moving the video in or out of the window remounts this tile, because the
// stage pin changes. Stash the zoom so the new instance picks it up.
const { capture, resync } = zoom
useLayoutEffect(() => {
return () => saveScreenShareZoom(trackSid, capture())
}, [trackSid, capture])
// SR announcement: announce zoom level on change, with a one-time pan hint
// on the first zoom above 100 % per session.
const prevZoomRef = useRef(zoom.zoomPercentage)
@@ -102,18 +116,17 @@ export const ScreenShareZoomableVideo = ({
}, [zoom.handleWheel, zoom.surfaceElRef, popout.isOpen])
// Open: focus the popup. Close: focus the button again (the toolbar remounts).
const { resync } = zoom
useLayoutEffect(() => {
resync()
resync(takeScreenShareZoom(trackSid) ?? undefined)
if (popout.isOpen) {
wasPoppedOut.current = true
popoutChromeRef.current?.focus()
return
}
if (!wasPoppedOut.current) return
if (!wasPoppedOut.current && !takePopoutButtonFocus(trackSid)) return
wasPoppedOut.current = false
popoutButtonRef.current?.focus()
}, [popout.isOpen, resync])
}, [popout.isOpen, resync, trackSid])
// LiveKit unsubscribes tiles it believes are off-screen. Its observer
// cannot measure an element living in another window and reads it as
@@ -167,26 +180,23 @@ export const ScreenShareZoomableVideo = ({
</>
)
// Video in the popup, placeholder in the meeting so the layout stays put.
// Video in the popup. The meeting layout does not keep a tile for it.
if (popout.isOpen && popout.container) {
return (
<>
<ScreenSharePopoutPlaceholder onReturn={popout.close} />
<ScreenSharePopoutPortal container={popout.container}>
<div
ref={popoutChromeRef}
role="group"
// Focusable on purpose: it carries the zoom key handler, and the
// popup body is an ancestor, so keys would never bubble to it.
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label={t('separateWindowLabel', { name: participantName })}
className={popoutChromeClassName}
>
{media}
</div>
</ScreenSharePopoutPortal>
</>
<ScreenSharePopoutPortal container={popout.container}>
<div
ref={popoutChromeRef}
role="group"
// Focusable on purpose: it carries the zoom key handler, and the
// popup body is an ancestor, so keys would never bubble to it.
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label={t('separateWindowLabel', { name: participantName })}
className={popoutChromeClassName}
>
{media}
</div>
</ScreenSharePopoutPortal>
)
}
@@ -1,8 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useCallback } from 'react'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import {
closeScreenSharePopout,
openScreenSharePopout,
screenSharePopoutStore,
} from '@/stores/screenSharePopout'
import {
getAuxiliaryWindowFeatures,
getAuxiliaryWindowSize,
@@ -10,57 +15,45 @@ import {
} from '@/utils/auxiliaryWindow'
type UseScreenSharePopoutOptions = {
trackSid: string
windowName: string
title: string
getVideoElement?: () => HTMLVideoElement | null
}
type PopoutTarget = {
window: Window
container: HTMLElement
}
/**
* Opens the screen share in another window. Closing it does not stop the
* share: the video just comes back into the meeting.
*
* The window lives in a store, not in this hook. Opening it drops the stage
* pin, so the tile moves from the focus layout into the grid and this
* component remounts. The store is what keeps the window open across that.
*
* A real popup, not the meeting PiP, that one is already taken, and a
* popup can be as large as another screen.
*/
export const useScreenSharePopout = ({
trackSid,
windowName,
title,
getVideoElement,
}: UseScreenSharePopoutOptions) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const [target, setTarget] = useState<PopoutTarget | null>(null)
const targetRef = useRef<PopoutTarget | null>(null)
const { entry } = useSnapshot(screenSharePopoutStore)
const isOpen = entry?.trackSid === trackSid
// Brings the video back into the meeting, from the toolbar button as well as
// from the window's own close button.
// from the window's own close button. Safe to call after this hook's
// component has unmounted: the listener sits on the popup, not on the tile.
const release = useCallback(() => {
if (!targetRef.current) return
// Focus the meeting first so we can put the cursor back on the button.
window.focus()
// Unmount while the other document is still alive: React cannot clean up
// children in a window that is already gone.
flushSync(() => {
targetRef.current = null
setTarget(null)
})
if (screenSharePopoutStore.entry?.trackSid !== trackSid) return
closeScreenSharePopout({ restorePin: true })
announce(t('separateWindowClosed'), 'assertive')
}, [announce, t])
const close = useCallback(() => {
const current = targetRef.current?.window
if (!current) return
release()
current.close()
}, [release])
}, [announce, t, trackSid])
const open = useCallback(() => {
if (targetRef.current) return
if (screenSharePopoutStore.entry) return
const { width, height } = getAuxiliaryWindowSize(getVideoElement?.())
// Open right away: waiting first (fullscreen, etc.) lets the browser
@@ -79,14 +72,16 @@ export const useScreenSharePopout = ({
try {
const container = initializeAuxiliaryWindow(next, { title })
// The window X does not go through our close() — still bring the video back.
next.addEventListener('pagehide', release, { once: true })
targetRef.current = { window: next, container }
setTarget(targetRef.current)
openScreenSharePopout({
trackSid,
popup: next,
container,
// The window X does not go through close() — still bring the video back.
onPopupClosed: release,
})
next.focus()
announce(t('separateWindowOpened'), 'assertive')
// Drop meeting fullscreen or we would only see the placeholder.
// Drop meeting fullscreen: the stage this share was filling goes away.
if (document.fullscreenElement) {
void document.exitFullscreen()
}
@@ -96,26 +91,22 @@ export const useScreenSharePopout = ({
})
next.close()
}
}, [announce, getVideoElement, release, t, title, windowName])
}, [announce, getVideoElement, release, t, title, trackSid, windowName])
const toggle = useCallback(() => {
if (targetRef.current) close()
if (screenSharePopoutStore.entry?.trackSid === trackSid) release()
else open()
}, [close, open])
// Tile gone (share ended, layout change): close a leftover empty window.
useEffect(() => {
return () => {
targetRef.current?.window.close()
targetRef.current = null
}
}, [])
}, [open, release, trackSid])
return {
isOpen: !!target,
container: target?.container ?? null,
isOpen,
// The snapshot deep-freezes the element. The portal needs the real node,
// which `ref()` kept out of the proxy.
container: isOpen
? (screenSharePopoutStore.entry?.container ?? null)
: null,
open,
close,
close: release,
toggle,
}
}
@@ -19,6 +19,8 @@ import {
getZoomTransform,
} from '../utils/screenShareZoom'
export type ZoomState = { zoom: number; pan: PanOffset }
/**
* Manages zoom and pan state for a remote screen share.
*
@@ -81,16 +83,25 @@ export const useScreenShareZoom = () => {
// After the video moves to the other window, write the current zoom back
// on the new nodes (otherwise it looks like 100 % until the next scroll).
const resync = useCallback(() => {
panRef.current = clampPan(
panRef.current,
zoomRef.current,
readPictureRatio()
)
applyTransform()
applyCursor()
syncToolbar()
}, [applyCursor, applyTransform, syncToolbar, readPictureRatio])
// Pass a state to adopt one captured before a remount; the pan is clamped
// against the new container either way.
const resync = useCallback(
(state?: ZoomState) => {
if (state) {
zoomRef.current = state.zoom
panRef.current = state.pan
}
panRef.current = clampPan(
panRef.current,
zoomRef.current,
readPictureRatio()
)
applyTransform()
applyCursor()
syncToolbar()
},
[applyCursor, applyTransform, syncToolbar, readPictureRatio]
)
const setZoom = useCallback(
(next: number) => {
@@ -266,6 +277,13 @@ export const useScreenShareZoom = () => {
[panBy, zoomIn, zoomOut, resetZoom]
)
// The tile remounts when the layout switches. Callers stash this across
// that remount so the popup keeps the zoom the user already had.
const capture = useCallback(
(): ZoomState => ({ zoom: zoomRef.current, pan: { ...panRef.current } }),
[]
)
return {
zoomPercentage: Math.round(zoomLevel * 100),
isZoomed: zoomLevel > MIN_ZOOM,
@@ -278,6 +296,7 @@ export const useScreenShareZoom = () => {
zoomOut,
resetZoom,
resync,
capture,
handleWheel,
handleKeyDown,
}
@@ -1,7 +1,7 @@
import { isWeb } from '@livekit/components-core'
import { MediaDeviceFailure, Track } from 'livekit-client'
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import {
ConnectionStateToast,
RoomAudioRenderer,
@@ -32,6 +32,7 @@ import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
import { closeScreenSharePopout } from '@/stores/screenSharePopout'
/**
* @public
@@ -78,6 +79,12 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
// Picture-in-picture replaces the stage, which is what renders a popped-out
// share. Bring it back rather than leave an empty window behind.
useEffect(() => {
if (isPictureInPictureOpen) closeScreenSharePopout({ restorePin: true })
}, [isPictureInPictureOpen])
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
const handleDeviceError = ({
+1 -4
View File
@@ -779,10 +779,7 @@
"separateWindowClosed": "Bildschirmfreigabe zurück in die Besprechung geholt",
"separateWindowBlocked": "Der Browser hat das eigene Fenster blockiert. Erlauben Sie Pop-ups für diese Website und versuchen Sie es erneut.",
"separateWindowTitle": "Bildschirmfreigabe von {{name}}",
"separateWindowLabel": "Bildschirmfreigabe von {{name}} in einem eigenen Fenster",
"placeholderTitle": "Diese Bildschirmfreigabe ist in einem anderen Fenster.",
"placeholderDescription": "Sie können dieses Fenster auf einen anderen Bildschirm verschieben. Die Besprechung bleibt hier.",
"placeholderBringBack": "Bildschirmfreigabe hierher zurückholen"
"separateWindowLabel": "Bildschirmfreigabe von {{name}} in einem eigenen Fenster"
},
"shortcutsPanel": {
"title": "Tastenkürzel",
+1 -4
View File
@@ -779,10 +779,7 @@
"separateWindowClosed": "Screen share returned to the meeting",
"separateWindowBlocked": "The browser blocked the separate window. Allow pop-ups for this site and try again.",
"separateWindowTitle": "{{name}}'s screen share",
"separateWindowLabel": "{{name}}'s screen share in a separate window",
"placeholderTitle": "This screen share is in another window.",
"placeholderDescription": "You can move that window to another screen. The meeting stays here.",
"placeholderBringBack": "Bring the screen share back here"
"separateWindowLabel": "{{name}}'s screen share in a separate window"
},
"shortcutsPanel": {
"title": "Keyboard shortcuts",
+1 -4
View File
@@ -778,10 +778,7 @@
"separateWindowClosed": "Pantalla compartida devuelta a la reunión",
"separateWindowBlocked": "El navegador bloqueó la ventana separada. Permite las ventanas emergentes para este sitio y vuelve a intentarlo.",
"separateWindowTitle": "Pantalla compartida de {{name}}",
"separateWindowLabel": "Pantalla compartida de {{name}} en una ventana separada",
"placeholderTitle": "Esta pantalla compartida está en otra ventana.",
"placeholderDescription": "Puedes mover esa ventana a otra pantalla. La reunión se queda aquí.",
"placeholderBringBack": "Traer la pantalla compartida aquí"
"separateWindowLabel": "Pantalla compartida de {{name}} en una ventana separada"
},
"shortcutsPanel": {
"title": "Atajos de teclado",
+1 -4
View File
@@ -779,10 +779,7 @@
"separateWindowClosed": "Partage d'écran ramené dans la réunion",
"separateWindowBlocked": "Le navigateur a bloqué la fenêtre séparée. Autorisez les pop-ups pour ce site, puis réessayez.",
"separateWindowTitle": "Partage d'écran de {{name}}",
"separateWindowLabel": "Partage d'écran de {{name}} dans une fenêtre séparée",
"placeholderTitle": "Ce partage d'écran est dans une autre fenêtre.",
"placeholderDescription": "Vous pouvez déplacer cette fenêtre vers un autre écran. La réunion reste ici.",
"placeholderBringBack": "Ramener le partage d'écran ici"
"separateWindowLabel": "Partage d'écran de {{name}} dans une fenêtre séparée"
},
"shortcutsPanel": {
"title": "Raccourcis clavier",
+1 -4
View File
@@ -779,10 +779,7 @@
"separateWindowClosed": "Schermdeling teruggezet in de vergadering",
"separateWindowBlocked": "De browser heeft het aparte venster geblokkeerd. Sta pop-ups toe voor deze site en probeer het opnieuw.",
"separateWindowTitle": "Schermdeling van {{name}}",
"separateWindowLabel": "Schermdeling van {{name}} in een apart venster",
"placeholderTitle": "Deze schermdeling staat in een ander venster.",
"placeholderDescription": "U kunt dat venster naar een ander scherm verplaatsen. De vergadering blijft hier.",
"placeholderBringBack": "Schermdeling hier terughalen"
"separateWindowLabel": "Schermdeling van {{name}} in een apart venster"
},
"shortcutsPanel": {
"title": "Sneltoetsen",
@@ -0,0 +1,123 @@
import { flushSync } from 'react-dom'
import { proxy, ref } from 'valtio'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import type { ZoomState } from '@/features/rooms/livekit/hooks/useScreenShareZoom'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
type Entry = {
trackSid: string
window: Window
container: HTMLElement
// Pin that was on screen when the window opened. Restored on close so the
// share (or whichever tile was focused) comes back. Absent when the meeting
// was already in grid view.
pinnedTrack?: TrackReferenceOrPlaceholder
detachListeners: () => void
}
export const screenSharePopoutStore = proxy<{ entry: Entry | null }>({
entry: null,
})
// Opening and closing both move the video between documents, which remounts
// the tile. These two slots carry what must survive that remount. They are
// armed by the transitions below, so an unrelated remount keeps its previous
// behaviour: the zoom resets and the focus stays where it was.
let armedSid: string | null = null
let carriedZoom: { trackSid: string; state: ZoomState } | null = null
let pendingButtonFocusSid: string | null = null
export const saveScreenShareZoom = (trackSid: string, state: ZoomState) => {
if (armedSid !== trackSid) return
// One save per transition, so a later unrelated remount starts from scratch.
armedSid = null
carriedZoom = { trackSid, state }
}
export const takeScreenShareZoom = (trackSid: string) => {
if (carriedZoom?.trackSid !== trackSid) return null
const { state } = carriedZoom
carriedZoom = null
return state
}
export const takePopoutButtonFocus = (trackSid: string) => {
if (pendingButtonFocusSid !== trackSid) return false
pendingButtonFocusSid = null
return true
}
export const openScreenSharePopout = ({
trackSid,
popup,
container,
onPopupClosed,
}: {
trackSid: string
popup: Window
container: HTMLElement
onPopupClosed: () => void
}) => {
const popupHidden = () => onPopupClosed()
// The meeting tab is going away: drop the window rather than run the
// bring-back flow, which would touch React while the page tears down.
const openerHidden = () => {
popup.removeEventListener('pagehide', popupHidden)
popup.close()
}
popup.addEventListener('pagehide', popupHidden, { once: true })
window.addEventListener('pagehide', openerHidden)
const pinnedTrack = layoutStore.pinnedTrackRef
armedSid = trackSid
screenSharePopoutStore.entry = {
trackSid,
window: ref(popup),
container: ref(container),
pinnedTrack: pinnedTrack ? ref(pinnedTrack) : undefined,
detachListeners: () => {
popup.removeEventListener('pagehide', popupHidden)
window.removeEventListener('pagehide', openerHidden)
},
}
// The share (or another pin) was filling the stage. Grid view leaves the
// rest of the meeting the whole window while the share is outside.
if (pinnedTrack) clearPinnedTrack()
}
// Clears the popout. The previous pin is restored only when the video is
// coming back: if the share itself ended, that pin points at a dead track.
export const closeScreenSharePopout = ({
restorePin,
}: {
restorePin: boolean
}) => {
const entry = screenSharePopoutStore.entry
if (!entry) return
const { window: popup, pinnedTrack, trackSid, detachListeners } = entry
detachListeners()
if (restorePin) {
// Both must be set before the render below: it remounts the tile, which
// reads them from its layout effect.
armedSid = trackSid
pendingButtonFocusSid = trackSid
// Put the cursor back in the meeting so the tile can focus its button.
window.focus()
} else {
armedSid = null
carriedZoom = null
pendingButtonFocusSid = null
}
// Unmount the portal while the other document is still alive, and put the
// pin back in the same render so the share doesn't flash through the grid.
flushSync(() => {
screenSharePopoutStore.entry = null
if (restorePin && pinnedTrack && !layoutStore.pinnedTrackRef) {
setPinnedTrack(pinnedTrack)
}
})
popup.close()
armedSid = null
}