From ca67e12bf58a1eeef11070e3afce6d219cbd3669 Mon Sep 17 00:00:00 2001 From: Ovgodd Date: Wed, 23 Sep 2026 12:28:53 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20free=20the=20meeting=20la?= =?UTF-8?q?yout=20while=20a=20screen=20share=20is=20popped=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unpin for grid view; keep screen share out until window closes --- CHANGELOG.md | 3 +- .../layout/components/StageLayout.tsx | 51 +++++++- .../components/ParticipantTile.tsx | 1 + .../ScreenSharePopoutPlaceholder.tsx | 45 ------- .../components/ScreenShareZoomableVideo.tsx | 56 ++++---- .../livekit/hooks/useScreenSharePopout.ts | 87 ++++++------- .../rooms/livekit/hooks/useScreenShareZoom.ts | 39 ++++-- .../rooms/livekit/prefabs/VideoConference.tsx | 9 +- src/frontend/src/locales/de/rooms.json | 5 +- src/frontend/src/locales/en/rooms.json | 5 +- src/frontend/src/locales/es/rooms.json | 5 +- src/frontend/src/locales/fr/rooms.json | 5 +- src/frontend/src/locales/nl/rooms.json | 5 +- src/frontend/src/stores/screenSharePopout.ts | 123 ++++++++++++++++++ 14 files changed, 289 insertions(+), 150 deletions(-) delete mode 100644 src/frontend/src/features/rooms/livekit/components/ScreenSharePopoutPlaceholder.tsx create mode 100644 src/frontend/src/stores/screenSharePopout.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b6c12ad..31db8a788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/src/features/layout/components/StageLayout.tsx b/src/frontend/src/features/layout/components/StageLayout.tsx index d4fc8e053..3acb9ac86 100644 --- a/src/frontend/src/features/layout/components/StageLayout.tsx +++ b/src/frontend/src/features/layout/components/StageLayout.tsx @@ -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 && ( + + )} {!pinnedTrackRef ? (
- +
diff --git a/src/frontend/src/features/participantTile/components/ParticipantTile.tsx b/src/frontend/src/features/participantTile/components/ParticipantTile.tsx index 16af2e9f5..efc779ac7 100644 --- a/src/frontend/src/features/participantTile/components/ParticipantTile.tsx +++ b/src/frontend/src/features/participantTile/components/ParticipantTile.tsx @@ -156,6 +156,7 @@ export const ParticipantTile: ( {videoTrack} diff --git a/src/frontend/src/features/rooms/livekit/components/ScreenSharePopoutPlaceholder.tsx b/src/frontend/src/features/rooms/livekit/components/ScreenSharePopoutPlaceholder.tsx deleted file mode 100644 index b2940ba32..000000000 --- a/src/frontend/src/features/rooms/livekit/components/ScreenSharePopoutPlaceholder.tsx +++ /dev/null @@ -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 ( -
- - {t('placeholderTitle')} - - - {t('placeholderDescription')} - - -
- ) -} 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 +}