- )
-}
diff --git a/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx
index e516dc5d5..60ab793dc 100644
--- a/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx
+++ b/src/frontend/src/features/rooms/livekit/components/ScreenShareZoomableVideo.tsx
@@ -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
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 (
- <>
-
-
-
- {media}
-
-
- >
+
+
+ {media}
+
+
)
}
diff --git a/src/frontend/src/features/rooms/livekit/hooks/useScreenSharePopout.ts b/src/frontend/src/features/rooms/livekit/hooks/useScreenSharePopout.ts
index c8a75e1aa..4ebbbf6d2 100644
--- a/src/frontend/src/features/rooms/livekit/hooks/useScreenSharePopout.ts
+++ b/src/frontend/src/features/rooms/livekit/hooks/useScreenSharePopout.ts
@@ -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(null)
- const targetRef = useRef(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,
}
}
diff --git a/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts b/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts
index 5f66ee489..29d6bdc22 100644
--- a/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts
+++ b/src/frontend/src/features/rooms/livekit/hooks/useScreenShareZoom.ts
@@ -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,
}
diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx
index 41a8fe960..3efb5af79 100644
--- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx
+++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx
@@ -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 = ({
diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json
index c6f12ec8e..0c91b70f3 100644
--- a/src/frontend/src/locales/de/rooms.json
+++ b/src/frontend/src/locales/de/rooms.json
@@ -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",
diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json
index 93af0c5f5..72bbf4ec8 100644
--- a/src/frontend/src/locales/en/rooms.json
+++ b/src/frontend/src/locales/en/rooms.json
@@ -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",
diff --git a/src/frontend/src/locales/es/rooms.json b/src/frontend/src/locales/es/rooms.json
index cc67ba0c7..e5962ac5e 100644
--- a/src/frontend/src/locales/es/rooms.json
+++ b/src/frontend/src/locales/es/rooms.json
@@ -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",
diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json
index b381faffe..86a5f423b 100644
--- a/src/frontend/src/locales/fr/rooms.json
+++ b/src/frontend/src/locales/fr/rooms.json
@@ -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",
diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json
index a91c66e21..ee6f76bd1 100644
--- a/src/frontend/src/locales/nl/rooms.json
+++ b/src/frontend/src/locales/nl/rooms.json
@@ -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",
diff --git a/src/frontend/src/stores/screenSharePopout.ts b/src/frontend/src/stores/screenSharePopout.ts
new file mode 100644
index 000000000..1d8197d53
--- /dev/null
+++ b/src/frontend/src/stores/screenSharePopout.ts
@@ -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
+}