diff --git a/lang/main.json b/lang/main.json index e0bde2de7fd6..379b6564e9e3 100644 --- a/lang/main.json +++ b/lang/main.json @@ -1539,7 +1539,9 @@ "videoSettings": "Video settings", "videomute": "Stop camera", "videomuteGUMPending": "Connecting your camera", - "videounmute": "Start camera" + "videounmute": "Start camera", + "pipOpen": "Open picture-in-picture", + "pipClose": "Close picture-in-picture" }, "transcribing": { "ccButtonTooltip": "Start / Stop subtitles", diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index aa0121e72597..77a0854b7487 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -599,6 +599,15 @@ export interface IConfig { pip?: { disabled?: boolean; showOnPrejoin?: boolean; + documentPiP: { + // @see: https://developer.chrome.com/docs/web-platform/document-picture-in-picture#methods + windowOptions: { + width: number; + height: number; + disallowReturnToOpener?: boolean; + preferInitialWindowPlacement?: boolean; + } + } }; preferBosh?: boolean; preferVisitor?: boolean; diff --git a/react/features/base/icons/svg/constants.ts b/react/features/base/icons/svg/constants.ts index 4bee92532f41..4af07b854ede 100644 --- a/react/features/base/icons/svg/constants.ts +++ b/react/features/base/icons/svg/constants.ts @@ -68,6 +68,7 @@ import { default as IconPerformance } from './performance.svg'; import { default as IconPhoneRinging } from './phone-ringing.svg'; import { default as IconPin } from './pin.svg'; import { default as IconPinned } from './pinned.svg'; +import { default as IconPip } from './pip.svg'; import { default as IconPlay } from './play.svg'; import { default as IconPlus } from './plus.svg'; import { default as IconRaiseHand } from './raise-hand.svg'; @@ -180,6 +181,7 @@ export const DEFAULT_ICON: Record = { IconPhoneRinging, IconPin, IconPinned, + IconPip, IconPlay, IconPlus, IconRaiseHand, diff --git a/react/features/base/icons/svg/index.ts b/react/features/base/icons/svg/index.ts index 3c77e1435550..3d5d204826f8 100644 --- a/react/features/base/icons/svg/index.ts +++ b/react/features/base/icons/svg/index.ts @@ -69,6 +69,7 @@ const { IconPerformance, IconPhoneRinging, IconPin, + IconPip, IconPinned, IconPlay, IconPlus, @@ -190,6 +191,7 @@ export { IconPerformance, IconPhoneRinging, IconPin, + IconPip, IconPinned, IconPlay, IconPlus, diff --git a/react/features/base/icons/svg/pip.svg b/react/features/base/icons/svg/pip.svg new file mode 100644 index 000000000000..88bbee337aae --- /dev/null +++ b/react/features/base/icons/svg/pip.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/pip/actions.ts b/react/features/pip/actions.ts index e43802bffc78..29fde5bea72a 100644 --- a/react/features/pip/actions.ts +++ b/react/features/pip/actions.ts @@ -7,11 +7,21 @@ import { muteLocal } from '../video-menu/actions.any'; import { SET_PIP_ACTIVE } from './actionTypes'; import { cleanupMediaSessionHandlers, - enterPiP, + clearPiPWindow, + enterVideoPiP, + getStoredPiPWindow, + initPiPWindow, setupMediaSessionHandlers, - shouldShowPiP + shouldShowPiP, } from './functions'; import logger from './logger'; +import { getDocumentPiPWindow, isDocumentPiPOpen, isDocumentPiPSupported } from "./utils"; + +/** + * Flag to track if a Document PiP request is currently pending. + * Prevents duplicate requestWindow() calls before the first one resolves. + */ +let docPiPPending = false; /** * Action to set Picture-in-Picture active state. @@ -63,6 +73,7 @@ export function toggleVideoFromPiP() { /** * Action to exit Picture-in-Picture mode. + * Handles both Document PiP and Video PiP. * * @returns {Function} */ @@ -70,14 +81,21 @@ export function exitPiP() { return (dispatch: IStore['dispatch']) => { logger.debug('exitPiP called'); + const pipWindow = getStoredPiPWindow(); + + if (pipWindow && !pipWindow.closed) { + pipWindow.close(); + clearPiPWindow(); + } + if (document.pictureInPictureElement) { document.exitPictureInPicture() - .then(() => { + .then(() => { logger.debug('Exited Picture-in-Picture mode'); - }) - .catch((err: Error) => { - logger.error(`Error while exiting PiP: ${err.message}`); - }); + }) + .catch((err: Error) => { + logger.error(`Error while exiting PiP: ${err.message}`); + }); } dispatch(setPiPActive(false)); @@ -100,7 +118,7 @@ export function handleWindowBlur(videoElement: HTMLVideoElement) { logger.debug(`Window blur detected, isPiPActive=${isPiPActive}`); if (!isPiPActive) { - enterPiP(videoElement); + enterVideoPiP(videoElement); } }; } @@ -163,7 +181,7 @@ export function handlePipEnterEvent() { * @returns {Function} */ export function showPiP() { - return (_dispatch: IStore['dispatch'], getState: IStore['getState']) => { + return (dispatch: IStore['dispatch'], getState: IStore['getState']) => { const state = getState(); const isPiPActive = state['features/pip']?.isPiPActive; const _shouldShowPip = shouldShowPiP(state); @@ -175,15 +193,19 @@ export function showPiP() { } if (!isPiPActive) { - const videoElement = document.getElementById('pipVideo') as HTMLVideoElement; + if (isDocumentPiPSupported()) { + dispatch(openDocumentPiP()); + } else { + const videoElement = document.getElementById('pipVideo') as HTMLVideoElement; - if (!videoElement) { - logger.warn('showPiP: pipVideo element not found'); + if (!videoElement) { + logger.warn('showPiP: pipVideo element not found'); - return; - } + return; + } - enterPiP(videoElement); + enterVideoPiP(videoElement); + } } }; } @@ -206,3 +228,93 @@ export function hidePiP() { } }; } + +export function enterPiP() { + return (dispatch: IStore["dispatch"]) => { + if (isDocumentPiPSupported()) { + const pipWindow = getDocumentPiPWindow(); + + if (pipWindow) { + dispatch(exitPiP()); + } else { + dispatch(openDocumentPiP()); + } + } else { + const videoElement = document.getElementById("pipVideo") as HTMLVideoElement; + + if (videoElement) { + enterVideoPiP(videoElement); + } + } + }; +} + +export function openDocumentPiP() { + return (dispatch: IStore["dispatch"], getState: IStore["getState"]) => { + if (!isDocumentPiPSupported()) { + logger.warn("Document Picture-in-Picture not supported, use Video PiP button"); + + return; + } + + const state = getState(); + const pipConfig = state["features/base/config"]?.pip; + const docPiPConfig = pipConfig?.documentPiP.windowOptions; + + if (isDocumentPiPOpen() || getStoredPiPWindow()) { + return; + } + + if (docPiPPending) { + logger.debug('Document PiP request already pending, skipping duplicate request'); + + return; + } + + const docPiP = (window as any).documentPictureInPicture; + + if (!docPiP) { + logger.warn("Document Picture-in-Picture not available"); + + return; + } + + docPiPPending = true; + + try { + const promise = docPiP.requestWindow({ + width: docPiPConfig?.width ?? 600, + height: docPiPConfig?.height ?? 450, + disallowReturnToOpener: docPiPConfig?.disallowReturnToOpener ?? false, + preferInitialWindowPlacement: docPiPConfig?.preferInitialWindowPlacement ?? false, + }); + + return promise + .then((pipWindow: Window) => { + initPiPWindow(pipWindow); + + dispatch(setPiPActive(true)); + + pipWindow.addEventListener("pagehide", () => { + clearPiPWindow(); + dispatch(setPiPActive(false)); + }); + }) + .catch((error: Error) => { + logger.error("Failed to open Document PiP:", error); + dispatch(setPiPActive(false)); + + throw error; + }) + .finally(() => { + docPiPPending = false; + }); + } catch (error) { + docPiPPending = false; + logger.error("Failed to open Document PiP:", error); + dispatch(setPiPActive(false)); + + throw error; + } + }; +} diff --git a/react/features/pip/components/PiP.tsx b/react/features/pip/components/PiP.tsx index c8863733f708..15cacb9ee6c8 100644 --- a/react/features/pip/components/PiP.tsx +++ b/react/features/pip/components/PiP.tsx @@ -1,19 +1,54 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { useSelector } from 'react-redux'; +import { IReduxState } from '../../app/types'; +import { MEDIA_TYPE } from '../../base/media/constants'; +import { isLocalTrackMuted } from '../../base/tracks/functions.any'; import { shouldShowPiP } from '../functions'; +import { useDocumentPiPMediaSession } from '../hooks'; import PiPVideoElement from './PiPVideoElement'; /** - * Wrapper component that conditionally renders PiPVideoElement. - * Prevents mounting when PiP is disabled or on prejoin without showOnPrejoin flag. + * Inner component for the Document PiP. + * + * @returns {React.ReactElement} + */ +function DocumentPiPContent() { + const playerRef = useRef(null); + const containerRef = useRef(null); + const audioMuted = useSelector( + (state: IReduxState) => isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO)); + const videoMuted = useSelector( + (state: IReduxState) => isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO)); + + useDocumentPiPMediaSession(playerRef, containerRef, !audioMuted, !videoMuted); + + return ( +
+
+ {/* TODO: document pip contents */} +
+
+ ); +} + +/** + * Wrapper component that selects the appropriate PiP implementation. + * Uses Document PiP API when available, falls back to Video PiP. * * @returns {React.ReactElement | null} */ function PiP() { const showPiP = useSelector(shouldShowPiP); + // Document PiP must mount regardless of shouldShowPiP + // because useDocumentPiPMediaSession registers the enterpictureinpicture + // MediaSession handler needed for tab-switch auto-open. + if ('documentPictureInPicture' in window) { + return ; + } + if (!showPiP) { return null; } diff --git a/react/features/pip/components/web/PiPTriggerButton.tsx b/react/features/pip/components/web/PiPTriggerButton.tsx new file mode 100644 index 000000000000..460cb7f72449 --- /dev/null +++ b/react/features/pip/components/web/PiPTriggerButton.tsx @@ -0,0 +1,45 @@ +import { connect } from 'react-redux'; + +import { createToolbarEvent } from '../../../analytics/AnalyticsEvents'; +import { sendAnalytics } from '../../../analytics/functions'; +import { IReduxState } from '../../../app/types'; +import { translate } from '../../../base/i18n/functions.web'; +import { IconPip } from '../../../base/icons/svg'; +import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton'; +import { enterPiP } from '../../actions'; + +interface IProps extends AbstractButtonProps { + _isDocPiPActive?: boolean; +} + +/** + * PiP toggle button + * Either opens or closes the existing picture in picture window + * Opens Document PiP or Video PiP based on browser availability and hides when both are not supported (eg: firefox). + */ +class PipTriggerButton extends AbstractButton { + override accessibilityLabel = 'toolbar.accessibilityLabel.pip'; + override label = 'toolbar.pipOpen'; + override toggledLabel = 'toolbar.pipClose'; + override tooltip = 'toolbar.pipOpen'; + override toggledTooltip = 'toolbar.pipClose'; + override icon = IconPip; + + override _isToggled(): boolean { + return Boolean(this.props._isDocPiPActive); + } + + override _handleClick() { + const { dispatch } = this.props; + sendAnalytics(createToolbarEvent('picture-in-picture')); + dispatch(enterPiP()); + } +} + +function mapStateToProps(state: IReduxState): Partial { + return { + _isDocPiPActive: Boolean(state['features/pip']?.isPiPActive) + }; +} + +export default connect(mapStateToProps)(translate(PipTriggerButton)); \ No newline at end of file diff --git a/react/features/pip/functions.ts b/react/features/pip/functions.ts index 119e777e7c66..eb6f8a992e4f 100644 --- a/react/features/pip/functions.ts +++ b/react/features/pip/functions.ts @@ -18,7 +18,7 @@ import { IMediaSessionState } from './types'; * * This prevents duplicate PiP entry requests that can occur on macOS when minimizing * a window. On minimize, both the 'blur' event and 'visibilitychange' event fire in - * rapid succession (within ~10ms), each triggering enterPiP(). Without this guard, + * rapid succession (within ~10ms), each triggering enterVideoPiP(). Without this guard, * Electron receives two PiP requests before the first one completes, causing the * first PiP to immediately exit and triggering a pip leave event that will cause the window to be restored. */ @@ -353,7 +353,7 @@ export function requestPictureInPicture() { * @param {HTMLVideoElement} videoElement - The video element to call requestPictureInPicuture on. * @returns {void} */ -export function enterPiP(videoElement: HTMLVideoElement | undefined | null) { +export function enterVideoPiP(videoElement: HTMLVideoElement | undefined | null) { if (!videoElement) { logger.error('PiP video element not found'); @@ -400,10 +400,19 @@ export function enterPiP(videoElement: HTMLVideoElement | undefined | null) { return; } - // TODO: Enable PiP for browsers: - // In browsers, we should directly call requestPictureInPicture. + pipRequestPending = true; + // @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions. - // requestPictureInPicture(); + videoElement.requestPictureInPicture() + .then(() => { + logger.debug('video.requestPictureInPicture() succeeded'); + }) + .catch((err: Error) => { + logger.error(`Error while requesting PiP: ${err.message}`); + }) + .finally(() => { + pipRequestPending = false; + }); } catch (error) { logger.error('Error entering Picture-in-Picture:', error); } @@ -501,6 +510,104 @@ export function cleanupMediaSessionHandlers() { } } +/** + * Reference to the currently opened window. + * It is required cause while using the pagehide event, we want to close the window that is open + */ +let _pipWindow: Window | null = null; + +/** + * Returns the stored window reference + * + * @returns {Window | null} + */ +export function getStoredPiPWindow(): Window | null { + return _pipWindow; +} + +/** + * Closes the stored window if open and clears the stored reference + * + * @returns {void} + */ +export function closeDocumentPiPWindow() { + const pipWindow = getStoredPiPWindow(); + + if (pipWindow && !pipWindow.closed) { + pipWindow.close(); + } + _pipWindow = null; +} + +/** + * Applies initial stylings of the main window to PiP window. + * Creates the container for the PiP window + * + * @param {Window} pipWindow - Current window + * @returns {void} + */ +export function initPiPWindow(pipWindow: Window) { + _pipWindow = pipWindow; + copyStylesheets(pipWindow); + createPiPContainer(pipWindow); +} + +/** + * Clears the PiP window reference + * + * @returns {void} + */ +export function clearPiPWindow() { + _pipWindow = null; +} + +/** + * Applies CSS style sheets from the originating window. + * + * @see https://developer.chrome.com/docs/web-platform/document-picture-in-picture#copy_style_sheets_to_pip + * @param {Window} pipWindow - current window + * @returns {void} + */ + +export function copyStylesheets(pipWindow: Window) { + const { document: pipDoc } = pipWindow; + + document.head.querySelectorAll('link[rel="stylesheet"], style').forEach(node => { + try { + if (node instanceof HTMLStyleElement) { + const style = pipDoc.createElement('style'); + + style.textContent = node.textContent || ''; + pipDoc.head.appendChild(style); + } else if (node instanceof HTMLLinkElement && node.href) { + const link = pipDoc.createElement('link'); + + link.rel = 'stylesheet'; + link.type = node.type; + link.href = node.href; + pipDoc.head.appendChild(link); + } + } catch (error) { + logger.warn('Failed to copy stylesheet:', error); + } + }); +} + +/** + * Creates container for pip. Helpful for react portals + * + * @see https://react.dev/reference/react-dom/createPortal + * @param {Window} pipWindow - current window + * @returns {void} + */ +function createPiPContainer(pipWindow: Window) { + const container = pipWindow.document.createElement('div'); + + container.id = 'pip-root'; + container.style.cssText = 'margin: 0; padding: 0; overflow: hidden; height: 100vh; width: 100vw; background: #141517;'; + pipWindow.document.body.appendChild(container); +} + // Re-export from shared file for external use. export { isPiPEnabled }; diff --git a/react/features/pip/hooks.ts b/react/features/pip/hooks.ts index bcee6b1225ce..e2a02f9fdb20 100644 --- a/react/features/pip/hooks.ts +++ b/react/features/pip/hooks.ts @@ -1,10 +1,11 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import IconUserSVG from '../base/icons/svg/user.svg?raw'; import { IParticipant } from '../base/participants/types'; import { TILE_ASPECT_RATIO } from '../filmstrip/constants'; -import { renderAvatarOnCanvas } from './functions'; +import { copyStylesheets, renderAvatarOnCanvas, updateMediaSessionState } from './functions'; +import { isDocumentPiPSupported } from './utils'; import logger from './logger'; /** @@ -181,3 +182,116 @@ export function useCanvasAvatar(options: IUseCanvasAvatarOptions): IUseCanvasAva canvasStreamRef: streamRef }; } + +/** + * Manages Document Picture-in-Picture via the MediaSession API. + * Opens a PiP window when a tab switch occurs using the + * enterpictureinpicture MediaSession action handler. + * Closes the PiP window when the tab becomes visible again. + * + * @see https://googlechrome.github.io/samples/media-session/video-conferencing.html + * + * @param {React.RefObject} playerRef - Ref to the player div to move into PiP. + * @param {React.RefObject} containerRef - Ref to the container div (player's parent). + * @param {boolean} microphoneActive - Whether the microphone is currently active. + * @param {boolean} cameraActive - Whether the camera is currently active. + * @returns {void} + */ +export function useDocumentPiPMediaSession( + playerRef: React.RefObject, + containerRef: React.RefObject, + microphoneActive: boolean, + cameraActive: boolean) { + const pipWindowRef = useRef(null); + + useEffect(() => { + updateMediaSessionState({ microphoneActive, cameraActive }); + }, [microphoneActive, cameraActive]); + + const openDocumentPip = useCallback(async () => { + const player = playerRef.current; + const container = containerRef.current; + + if (!player || !container) { + return; + } + if (!isDocumentPiPSupported()) { + return; + } + if (pipWindowRef.current && !pipWindowRef.current.closed) { + return; + } + + try { + const pipWindow = await (window as any).documentPictureInPicture.requestWindow({ + width: 600, + height: 450, + disallowReturnToOpener: false, + preferInitialWindowPlacement: false, + }); + + pipWindowRef.current = pipWindow; + + copyStylesheets(pipWindow); + + pipWindow.document.body.style.cssText = 'margin:0;background:#000;'; + pipWindow.document.body.appendChild(player); + + pipWindow.addEventListener('pagehide', () => { + container.appendChild(player); + pipWindowRef.current = null; + }); + } catch (error) { + logger.warn('Failed to open Document PiP:', error); + } + }, [playerRef, containerRef]); + + useEffect(() => { + if (!isDocumentPiPSupported()) { + return; + } + + try { + // @ts-ignore - enterpictureinpicture is a newer MediaSession action. + navigator.mediaSession.setActionHandler('enterpictureinpicture', async (details: any) => { + const reason = details?.enterPictureInPictureReason; + + if (reason === 'useraction') { + logger.log('User clicked Enter Picture-in-Picture icon.'); + } else if (reason === 'contentoccluded') { + logger.log('Automatically enter picture-in-picture.'); + } + + await openDocumentPip(); + }); + } catch (error) { + logger.warn('enterpictureinpicture MediaSession action not supported:', error); + } + + return () => { + navigator.mediaSession.setActionHandler('enterpictureinpicture' as any, null); + }; + }, [openDocumentPip]); + + useEffect(() => { + if (!isDocumentPiPSupported()) { + return; + } + + const onVisibilityChange = () => { + if (!document.hidden && pipWindowRef.current && !pipWindowRef.current.closed) { + if (playerRef.current && containerRef.current) { + containerRef.current.appendChild(playerRef.current); + } + pipWindowRef.current.close(); + pipWindowRef.current = null; + } + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [playerRef, containerRef]); +} diff --git a/react/features/pip/utils.ts b/react/features/pip/utils.ts new file mode 100644 index 000000000000..9f76bfc2d02c --- /dev/null +++ b/react/features/pip/utils.ts @@ -0,0 +1,69 @@ +/** + * Document Picture-in-Picture utility functions. + */ + +declare global { + interface IDocumentPictureInPicture { + addEventListener: ( + type: 'enter' | 'leave', + listener: ((event: IDocumentPictureInPictureEvent) => void) | ((event: Event) => void) + ) => void; + requestWindow: (options?: IDocumentPictureInPictureOptions) => Promise; + readonly window: Window | null; + } + + interface IDocumentPictureInPictureOptions { + disallowReturnToOpener?: boolean; + height?: number; + preferInitialWindowPlacement?: boolean; + width?: number; + } + + interface IDocumentPictureInPictureEvent extends Event { + readonly window: Window; + } + + interface IWindow { + documentPictureInPicture?: IDocumentPictureInPicture; + } +} + +export interface IDocPiPParticipant { + id: string; + isLocal: boolean; + isPinned: boolean; + name: string; +} + +/** + * Checks if the Document Picture-in-Picture API is supported. + * + * @returns {boolean} True if Document PiP is supported. + */ +export function isDocumentPiPSupported(): boolean { + return 'documentPictureInPicture' in window; +} + +/** + * Gets the current Document PiP window if one is active. + * + * @returns {Window | null} The PiP window or null. + */ +export function getDocumentPiPWindow(): Window | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pip = (window as any).documentPictureInPicture as IDocumentPictureInPicture | undefined; + + return pip?.window ?? null; +} + +/** + * Checks if Document PiP window is currently open. + * + * @returns {boolean} True if PiP window is open. + */ +export function isDocumentPiPOpen(): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pip = (window as any).documentPictureInPicture as IDocumentPictureInPicture | undefined; + + return pip?.window !== null && pip?.window !== undefined; +} diff --git a/react/features/toolbox/constants.ts b/react/features/toolbox/constants.ts index 5116c6e573b8..1892d3c685a9 100644 --- a/react/features/toolbox/constants.ts +++ b/react/features/toolbox/constants.ts @@ -151,6 +151,7 @@ export const TOOLBAR_BUTTONS: ToolbarButton[] = [ 'closedcaptions', 'custom-panel', 'desktop', + 'toggle-pip', 'download', 'embedmeeting', 'etherpad', diff --git a/react/features/toolbox/hooks.web.ts b/react/features/toolbox/hooks.web.ts index b780dbcf283e..de08b3e9ede6 100644 --- a/react/features/toolbox/hooks.web.ts +++ b/react/features/toolbox/hooks.web.ts @@ -35,6 +35,7 @@ import { getParticipantsPaneOpen, isParticipantsPaneEnabled } from '../participants-pane/functions'; +import PiPTriggerButton from '../pip/components/web/PiPTriggerButton'; import { useParticipantPaneButton } from '../participants-pane/hooks.web'; import { usePollsButton } from '../polls/hooks.web'; import { addReactionToBuffer } from '../reactions/actions.any'; @@ -171,6 +172,12 @@ const help = { group: 4 }; +const togglePiP = { + key: 'toggle-pip', + Content: PiPTriggerButton, + group: 2 +}; + /** * A hook that returns the toggle camera button if it is enabled and undefined otherwise. * @@ -274,6 +281,18 @@ function useInviteButton() { } } +/** + * Hide PiP toggle button when browser supports netiher Document PiP nor Video PiP (eg: firefox) + * + * @returns {Object | undefined} + */ +function usePipToggleButton() { + //TODO: add support for Video PiP fallback. Hide only when both are not supported + if ('documentPictureInPicture' in window) { + return togglePiP; + } +} + /** * Returns all buttons that could be rendered. * @@ -309,6 +328,7 @@ export function useToolboxButtons( const _help = useHelpButton(); const _invite = useInviteButton(); const customPanel = useCustomPanelButton(); + const togglePiPButton = usePipToggleButton(); const buttons: { [key in ToolbarButton]?: IToolboxButton; } = { microphone, @@ -321,6 +341,7 @@ export function useToolboxButtons( 'participants-pane': participants, invite: _invite, tileview, + 'toggle-pip': togglePiPButton, 'toggle-camera': toggleCameraButton, videoquality: videoQuality, fullscreen: _fullscreen, diff --git a/react/features/toolbox/types.ts b/react/features/toolbox/types.ts index e3bbb4858017..85ce95e59b3c 100644 --- a/react/features/toolbox/types.ts +++ b/react/features/toolbox/types.ts @@ -21,6 +21,7 @@ export type ToolbarButton = 'camera' | 'closedcaptions' | 'custom-panel' | 'desktop' | + 'toggle-pip' | 'download' | 'embedmeeting' | 'etherpad' |