mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-24 03:36:28 +00:00
✨(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:
+2
-1
@@ -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 = ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user