snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
25
react/features/pip/actionTypes.ts
Normal file
25
react/features/pip/actionTypes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Action type to set Picture-in-Picture active state.
|
||||
*/
|
||||
export const SET_PIP_ACTIVE = 'SET_PIP_ACTIVE';
|
||||
|
||||
/**
|
||||
* Action type to store the Document PiP window reference.
|
||||
*/
|
||||
export const SET_PIP_WINDOW = 'SET_PIP_WINDOW';
|
||||
|
||||
/**
|
||||
* Internal action signalling that the host confirmed a Document PiP window has opened.
|
||||
*/
|
||||
export const HOST_DOCUMENT_PIP_OPENED = 'HOST_DOCUMENT_PIP_OPENED';
|
||||
|
||||
/**
|
||||
* Internal action signalling that the host confirmed a Document PiP window has closed
|
||||
* or that opening it failed.
|
||||
*/
|
||||
export const HOST_DOCUMENT_PIP_CLOSED = 'HOST_DOCUMENT_PIP_CLOSED';
|
||||
|
||||
/**
|
||||
* Internal action carrying an ordered WebRTC signal from the embedding page.
|
||||
*/
|
||||
export const HOST_DOCUMENT_PIP_SIGNAL_RECEIVED = 'HOST_DOCUMENT_PIP_SIGNAL_RECEIVED';
|
||||
537
react/features/pip/actions.ts
Normal file
537
react/features/pip/actions.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import type { MediaCastSignal } from '../base/media-cast/types.web';
|
||||
import { isLocalTrackMuted } from '../base/tracks/functions.any';
|
||||
import { isEmbedded } from '../base/util/embedUtils';
|
||||
import { showErrorNotification } from '../notifications/actions';
|
||||
import { handleToggleVideoMuted } from '../toolbox/actions.any';
|
||||
import { muteLocal } from '../video-menu/actions.any';
|
||||
|
||||
import {
|
||||
HOST_DOCUMENT_PIP_CLOSED,
|
||||
HOST_DOCUMENT_PIP_OPENED,
|
||||
HOST_DOCUMENT_PIP_SIGNAL_RECEIVED,
|
||||
SET_PIP_ACTIVE,
|
||||
SET_PIP_WINDOW
|
||||
} from './actionTypes';
|
||||
import { DEFAULT_DOCUMENT_PIP_HEIGHT, DEFAULT_DOCUMENT_PIP_WIDTH } from './constants';
|
||||
import {
|
||||
cleanupMediaSessionHandlers,
|
||||
enterVideoPiP,
|
||||
initPiPWindow,
|
||||
isDocumentPiPRequestPending,
|
||||
isDocumentPiPSupported,
|
||||
setDocumentPiPRequestPending,
|
||||
setupMediaSessionHandlers,
|
||||
shouldShowPiP,
|
||||
} from './functions';
|
||||
import logger from './logger';
|
||||
import type { IOpenDocumentPiPOptions, IWebKitPictureInPictureVideoElement } from './types';
|
||||
|
||||
/**
|
||||
* Whether the current host-owned request should notify the user if opening fails.
|
||||
*/
|
||||
let hostDocumentPiPNotifyOnFailure = false;
|
||||
|
||||
/**
|
||||
* Clears the pending host-owned PiP request state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function clearHostDocumentPiPPendingState() {
|
||||
hostDocumentPiPNotifyOnFailure = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to set Picture-in-Picture active state.
|
||||
*
|
||||
* @param {boolean} isPiPActive - Whether PiP is active.
|
||||
* @returns {{
|
||||
* type: SET_PIP_ACTIVE,
|
||||
* isPiPActive: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setPiPActive(isPiPActive: boolean) {
|
||||
return {
|
||||
type: SET_PIP_ACTIVE,
|
||||
isPiPActive
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to store the Document PiP window reference.
|
||||
*
|
||||
* @param {Window|null} pipWindow - The open Document PiP window, or null when none is open.
|
||||
* @returns {{
|
||||
* type: SET_PIP_WINDOW,
|
||||
* pipWindow: (Window|null)
|
||||
* }}
|
||||
*/
|
||||
export function setPiPWindow(pipWindow: Window | null) {
|
||||
return {
|
||||
type: SET_PIP_WINDOW,
|
||||
pipWindow
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles audio mute from PiP MediaSession controls.
|
||||
* Uses exact same logic as toolbar audio button including GUM pending state.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function toggleAudioFromPiP() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
|
||||
|
||||
// Use the exact same action as toolbar button.
|
||||
dispatch(muteLocal(!audioMuted, MEDIA_TYPE.AUDIO));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles video mute from PiP MediaSession controls.
|
||||
* Uses exact same logic as toolbar video button including GUM pending state.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function toggleVideoFromPiP() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
|
||||
|
||||
// Use the exact same action as toolbar button (showUI=true, ensureTrack=true).
|
||||
dispatch(handleToggleVideoMuted(!videoMuted, true, true));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to exit Picture-in-Picture mode.
|
||||
* Handles both Document PiP and Video PiP.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function exitPiP() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
logger.debug('exitPiP called');
|
||||
clearHostDocumentPiPPendingState();
|
||||
|
||||
const wasActive = getState()['features/pip']?.isPiPActive;
|
||||
const { pipWindow } = getState()['features/pip'];
|
||||
|
||||
if (pipWindow) {
|
||||
// Clear the reference before close() so the window's pagehide listener runs against
|
||||
// the already-cleared state, mirroring the browser-initiated close flow.
|
||||
dispatch(setPiPWindow(null));
|
||||
|
||||
if (!pipWindow.closed) {
|
||||
pipWindow.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmbedded() && isDocumentPiPSupported()) {
|
||||
setDocumentPiPRequestPending(false);
|
||||
APP.API.notifyDocumentPiPClose();
|
||||
|
||||
// hidePiP() may run while the host has not answered yet, in which case PiP never
|
||||
// opened and emitting pipLeft without a matching pipEntered would be unbalanced.
|
||||
if (wasActive) {
|
||||
dispatch(handlePiPLeaveEvent());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const webKitPiPVideo = document.getElementById('pipVideo') as IWebKitPictureInPictureVideoElement | null;
|
||||
|
||||
if (webKitPiPVideo?.webkitPresentationMode === 'picture-in-picture'
|
||||
&& typeof webKitPiPVideo.webkitSetPresentationMode === 'function') {
|
||||
try {
|
||||
webKitPiPVideo.webkitSetPresentationMode('inline');
|
||||
} catch (error) {
|
||||
logger.error('Error while exiting WebKit PiP:', error);
|
||||
}
|
||||
} else if (document.pictureInPictureElement) {
|
||||
document.exitPictureInPicture()
|
||||
.then(() => {
|
||||
logger.debug('Exited Picture-in-Picture mode');
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
logger.error(`Error while exiting PiP: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(setPiPActive(false));
|
||||
cleanupMediaSessionHandlers();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to handle window blur or tab switch.
|
||||
* Enters PiP mode if not already active.
|
||||
*
|
||||
* @param {HTMLVideoElement} videoElement - The video element we will use for PiP.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handleWindowBlur(videoElement: HTMLVideoElement) {
|
||||
return (_dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const isPiPActive = state['features/pip']?.isPiPActive;
|
||||
|
||||
logger.debug(`Window blur detected, isPiPActive=${isPiPActive}`);
|
||||
|
||||
if (!isPiPActive) {
|
||||
enterVideoPiP(videoElement);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to handle window focus.
|
||||
* Exits PiP mode if currently active (matches old AOT behavior).
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handleWindowFocus() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const isPiPActive = state['features/pip']?.isPiPActive;
|
||||
|
||||
logger.debug(`Window focus detected, isPiPActive=${isPiPActive}`);
|
||||
|
||||
if (isPiPActive) {
|
||||
dispatch(exitPiP());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to handle the browser's leavepictureinpicture event.
|
||||
* Updates state and cleans up MediaSession handlers.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handlePiPLeaveEvent() {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
logger.log('Left Picture-in-Picture mode');
|
||||
|
||||
dispatch(setPiPActive(false));
|
||||
cleanupMediaSessionHandlers();
|
||||
APP.API.notifyPictureInPictureLeft();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to handle the browser's enterpictureinpicture event.
|
||||
* Updates state and sets up MediaSession handlers.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handlePipEnterEvent() {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
logger.log('Entered Picture-in-Picture mode');
|
||||
|
||||
dispatch(setPiPActive(true));
|
||||
setupMediaSessionHandlers(dispatch);
|
||||
APP.API.notifyPictureInPictureEntered();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows Picture-in-Picture window.
|
||||
* Called from external API when iframe becomes not visible (IntersectionObserver).
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function showPiP() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const isPiPActive = state['features/pip']?.isPiPActive;
|
||||
const _shouldShowPip = shouldShowPiP(state);
|
||||
|
||||
logger.debug(`showPiP called, shouldShow=${_shouldShowPip}, isPiPActive=${isPiPActive}`);
|
||||
|
||||
if (!_shouldShowPip) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPiPActive) {
|
||||
if (isDocumentPiPSupported()) {
|
||||
dispatch(openDocumentPiP());
|
||||
} else {
|
||||
const videoElement = document.getElementById('pipVideo') as HTMLVideoElement;
|
||||
|
||||
if (!videoElement) {
|
||||
logger.warn('showPiP: pipVideo element not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
enterVideoPiP(videoElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides Picture-in-Picture window.
|
||||
* Called from external API when iframe becomes visible.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function hidePiP() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
clearHostDocumentPiPPendingState();
|
||||
|
||||
const state = getState();
|
||||
const isPiPActive = state['features/pip']?.isPiPActive;
|
||||
const embeddedRequestPending = isEmbedded()
|
||||
&& isDocumentPiPSupported()
|
||||
&& isDocumentPiPRequestPending();
|
||||
|
||||
logger.debug(`hidePiP called, isPiPActive=${isPiPActive}`);
|
||||
|
||||
if (isPiPActive || embeddedRequestPending) {
|
||||
dispatch(exitPiP());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles PiP based on the current state and browser support.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
|
||||
export function togglePip() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const isPiPActive = state['features/pip']?.isPiPActive;
|
||||
const _shouldShowPip = shouldShowPiP(state);
|
||||
|
||||
logger.debug(`togglePip called, shouldShow=${_shouldShowPip}, isPiPActive=${isPiPActive}`);
|
||||
|
||||
if (!_shouldShowPip) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPiPActive) {
|
||||
dispatch(exitPiP());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDocumentPiPSupported()) {
|
||||
dispatch(openDocumentPiP({ notifyOnFailure: true }));
|
||||
} else {
|
||||
const videoElement = document.getElementById('pipVideo') as HTMLVideoElement;
|
||||
|
||||
if (videoElement) {
|
||||
enterVideoPiP(videoElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Document PiP from the toolbar or an automatic MediaSession request.
|
||||
* Embedded meetings only request the host-owned window; the host config is the
|
||||
* single source of truth for window options.
|
||||
*
|
||||
* @param {IOpenDocumentPiPOptions} options - Options controlling user-facing failure handling.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function openDocumentPiP(options: IOpenDocumentPiPOptions = {}) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const _shouldShowPip = shouldShowPiP(state);
|
||||
|
||||
if (!_shouldShowPip) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Electron must not change and must never ask the host for a Document PiP window; browsers
|
||||
// without the Document PiP API fall back to the Video PiP element instead.
|
||||
if (browser.isElectron() || !isDocumentPiPSupported()) {
|
||||
logger.warn('Document Picture-in-Picture not supported');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmbedded()) {
|
||||
if (state['features/pip']?.isPiPActive || isDocumentPiPRequestPending()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDocumentPiPRequestPending(true);
|
||||
hostDocumentPiPNotifyOnFailure = Boolean(options.notifyOnFailure);
|
||||
APP.API.notifyDocumentPiPRequested();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const docPiP = window.documentPictureInPicture;
|
||||
|
||||
if (!docPiP) {
|
||||
logger.warn('Document Picture-in-Picture not supported');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const pipConfig = state['features/base/config']?.pip;
|
||||
const docPiPConfig = pipConfig?.documentPiP?.windowOptions;
|
||||
const docPiPWindow = docPiP.window;
|
||||
const storedWindow = state['features/pip'].pipWindow;
|
||||
|
||||
// Two sources can diverge: storedWindow is the window this feature opened and initialized, while
|
||||
// docPiP.window is the browser's view of any Document PiP window open for this page. Only one Document PiP
|
||||
// window may exist per page and requestWindow() closes an existing one, so do not open if either is active.
|
||||
const isPiPWindowAlreadyOpen = Boolean(
|
||||
(storedWindow && !storedWindow.closed) || (docPiPWindow && !docPiPWindow.closed));
|
||||
|
||||
if (isPiPWindowAlreadyOpen) {
|
||||
logger.debug('Document PiP is already open');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedWindow?.closed) {
|
||||
dispatch(setPiPWindow(null));
|
||||
}
|
||||
|
||||
if (isDocumentPiPRequestPending()) {
|
||||
logger.debug('Document PiP request already pending, skipping duplicate request');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDocumentPiPRequestPending(true);
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
logger.error('Failed to open Document PiP:', error);
|
||||
|
||||
if (options.notifyOnFailure) {
|
||||
dispatch(showErrorNotification({
|
||||
descriptionKey: 'notify.pipOpenFailedDescription',
|
||||
titleKey: 'notify.pipOpenFailedTitle'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return docPiP.requestWindow({
|
||||
width: docPiPConfig?.width ?? DEFAULT_DOCUMENT_PIP_WIDTH,
|
||||
height: docPiPConfig?.height ?? DEFAULT_DOCUMENT_PIP_HEIGHT,
|
||||
disallowReturnToOpener: docPiPConfig?.disallowReturnToOpener ?? false,
|
||||
preferInitialWindowPlacement: docPiPConfig?.preferInitialWindowPlacement ?? false,
|
||||
})
|
||||
.then((pipWindow: Window) => {
|
||||
// The window can be closed before this resolves; nothing has been stored or
|
||||
// dispatched for it yet, so simply do not initialize it.
|
||||
if (pipWindow.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
pipWindow.addEventListener('pagehide', () => {
|
||||
dispatch(setPiPWindow(null));
|
||||
dispatch(handlePiPLeaveEvent());
|
||||
});
|
||||
|
||||
initPiPWindow(pipWindow);
|
||||
dispatch(setPiPWindow(pipWindow));
|
||||
dispatch(handlePipEnterEvent());
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
setDocumentPiPRequestPending(false);
|
||||
});
|
||||
} catch (error) {
|
||||
setDocumentPiPRequestPending(false);
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the host acknowledgement once the parent-owned document and the dedicated Document PiP
|
||||
* renderer bundle are ready, and signals the sender to start streaming.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handleHostDocumentPiPOpened() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
clearHostDocumentPiPPendingState();
|
||||
setDocumentPiPRequestPending(false);
|
||||
|
||||
const state = getState();
|
||||
|
||||
if (!shouldShowPiP(state)) {
|
||||
APP.API.notifyDocumentPiPClose();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state['features/pip']?.isPiPActive) {
|
||||
dispatch(handlePipEnterEvent());
|
||||
}
|
||||
|
||||
dispatch({ type: HOST_DOCUMENT_PIP_OPENED });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the request guard after the host rejects requestWindow or resource setup.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handleHostDocumentPiPOpenFailed() {
|
||||
return (dispatch: IStore['dispatch']) => {
|
||||
const notifyOnFailure = hostDocumentPiPNotifyOnFailure;
|
||||
|
||||
logger.warn('Embedded Document PiP open failed.');
|
||||
clearHostDocumentPiPPendingState();
|
||||
setDocumentPiPRequestPending(false);
|
||||
dispatch({ type: HOST_DOCUMENT_PIP_CLOSED });
|
||||
|
||||
if (notifyOnFailure) {
|
||||
dispatch(showErrorNotification({
|
||||
descriptionKey: 'notify.pipOpenFailedDescription',
|
||||
titleKey: 'notify.pipOpenFailedTitle'
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the authoritative close acknowledgement from the embedding page.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function handleHostDocumentPiPWindowClosed() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const wasActive = getState()['features/pip']?.isPiPActive;
|
||||
|
||||
clearHostDocumentPiPPendingState();
|
||||
setDocumentPiPRequestPending(false);
|
||||
dispatch({ type: HOST_DOCUMENT_PIP_CLOSED });
|
||||
|
||||
if (wasActive) {
|
||||
dispatch(handlePiPLeaveEvent());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the one internal signaling union into the ordered sender queue.
|
||||
*
|
||||
* @param {MediaCastSignal} signal - WebRTC signal from the embedding page.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function handleHostDocumentPiPSignal(signal: MediaCastSignal) {
|
||||
return {
|
||||
type: HOST_DOCUMENT_PIP_SIGNAL_RECEIVED,
|
||||
signal
|
||||
};
|
||||
}
|
||||
48
react/features/pip/components/PiP.tsx
Normal file
48
react/features/pip/components/PiP.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { browser } from '../../base/lib-jitsi-meet';
|
||||
import { isEmbedded } from '../../base/util/embedUtils';
|
||||
import { isDocumentPiPSupported, shouldShowPiP } from '../functions';
|
||||
import { useDocumentPiPMediaSession } from '../hooks';
|
||||
|
||||
import PiPVideoElement from './PiPVideoElement';
|
||||
import { DocumentPiPContent } from './web/DocumentPiPContent';
|
||||
|
||||
/**
|
||||
* Document PiP support cannot change during the page lifetime, so it is computed once at module
|
||||
* load instead of on every render.
|
||||
*/
|
||||
const IS_DOCUMENT_PIP_SUPPORTED = isDocumentPiPSupported();
|
||||
|
||||
/**
|
||||
* Wrapper component that selects the appropriate PiP implementation.
|
||||
* Uses Document PiP API when available, falls back to Video PiP.
|
||||
* Embedded meetings defer to the host-owned Document PiP window.
|
||||
*
|
||||
* @returns {React.ReactElement | null}
|
||||
*/
|
||||
function PiP() {
|
||||
useDocumentPiPMediaSession();
|
||||
|
||||
const showPiP = useSelector(shouldShowPiP);
|
||||
|
||||
if (!showPiP) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEmbedded()) {
|
||||
// The host owns the Document PiP document. Electron stays on Video PiP and browsers
|
||||
// without the Document PiP API fall back to the existing Video PiP element; everywhere
|
||||
// else the embedded meeting renders nothing here.
|
||||
return browser.isElectron() || !IS_DOCUMENT_PIP_SUPPORTED ? <PiPVideoElement /> : null;
|
||||
}
|
||||
|
||||
if (IS_DOCUMENT_PIP_SUPPORTED) {
|
||||
return <DocumentPiPContent />;
|
||||
}
|
||||
|
||||
return <PiPVideoElement />;
|
||||
}
|
||||
|
||||
export default PiP;
|
||||
430
react/features/pip/components/PiPVideoElement.tsx
Normal file
430
react/features/pip/components/PiPVideoElement.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { getAvatarFont, getAvatarInitialsColor } from '../../base/avatar/components/web/styles';
|
||||
import { browser } from '../../base/lib-jitsi-meet';
|
||||
import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants/functions';
|
||||
import { getDisplayNameColor } from '../../display-name/components/web/styles';
|
||||
import { getThumbnailBackgroundColor } from '../../filmstrip/functions.web';
|
||||
import { getLargeVideoParticipant } from '../../large-video/functions';
|
||||
import { isPrejoinPageVisible } from '../../prejoin/functions.any';
|
||||
import { handlePiPLeaveEvent, handlePipEnterEvent, handleWindowBlur, handleWindowFocus } from '../actions';
|
||||
import { getPiPVideoTrack, shouldShowPiPAvatar } from '../functions';
|
||||
import { useCanvasAvatar } from '../hooks';
|
||||
import logger from '../logger';
|
||||
import type { IWebKitPictureInPictureVideoElement } from '../types';
|
||||
|
||||
/**
|
||||
* Electron-only delay before reacting to window focus, so that the browser's
|
||||
* leavepictureinpicture event is processed first (see the comment at the usage site).
|
||||
*/
|
||||
const FOCUS_CHECK_DELAY_MS = 100;
|
||||
|
||||
/**
|
||||
* Interval between canvas frame re-publish attempts while WebKit asynchronously wires the video
|
||||
* element to the canvas stream (see the comment at the usage site for why polling is needed).
|
||||
*/
|
||||
const WEBKIT_REPUBLISH_INTERVAL_MS = 50;
|
||||
|
||||
/**
|
||||
* Cap on canvas frame re-publish attempts (a ~1.25 s total budget). One attempt has been enough
|
||||
* in practice (Safari 26.6); the bound only prevents polling forever against a dead stream.
|
||||
*/
|
||||
const WEBKIT_REPUBLISH_MAX_ATTEMPTS = 25;
|
||||
|
||||
const baseVideoStyle = {
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none' as const,
|
||||
opacity: 0,
|
||||
position: 'absolute' as const
|
||||
};
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
hiddenVideo: {
|
||||
...baseVideoStyle,
|
||||
left: '-9999px',
|
||||
top: '-9999px'
|
||||
},
|
||||
// Safari 26.5.2 was observed to produce black PiP content when this video was positioned offscreen.
|
||||
webKitVideo: {
|
||||
...baseVideoStyle,
|
||||
left: 0,
|
||||
top: 0
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders a hidden video element for Picture-in-Picture.
|
||||
* Automatically switches between real video track and canvas-based avatar
|
||||
* depending on video availability.
|
||||
*
|
||||
* @returns {JSX.Element} The hidden video element.
|
||||
*/
|
||||
const PiPVideoElement: React.FC = () => {
|
||||
const { classes, theme } = useStyles();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const previousTrackRef = useRef<any>(null);
|
||||
|
||||
// True only when PiP was dismissed while the tab was still hidden; reset once the tab becomes
|
||||
// visible again. Safari 26.5.2 fires `playing` after such a dismissal, which would immediately
|
||||
// reopen PiP, so automatic re-entry is suppressed while this is set.
|
||||
const webKitPiPDismissedWhileHiddenRef = useRef(false);
|
||||
|
||||
// Redux selectors.
|
||||
const isOnPrejoin = useSelector(isPrejoinPageVisible);
|
||||
const localParticipant = useSelector(getLocalParticipant);
|
||||
const largeVideoParticipant = useSelector(getLargeVideoParticipant);
|
||||
|
||||
// Use local participant during prejoin, otherwise large video participant.
|
||||
const participant = isOnPrejoin ? localParticipant : largeVideoParticipant;
|
||||
|
||||
// Get appropriate video track based on prejoin state.
|
||||
const videoTrack = useSelector((state: IReduxState) =>
|
||||
getPiPVideoTrack(state, participant)
|
||||
);
|
||||
const displayName = useSelector((state: IReduxState) =>
|
||||
participant?.id
|
||||
? getParticipantDisplayName(state, participant.id)
|
||||
: ''
|
||||
);
|
||||
const customAvatarBackgrounds = useSelector((state: IReduxState) =>
|
||||
state['features/dynamic-branding']?.avatarBackgrounds || []
|
||||
);
|
||||
|
||||
const dispatch: IStore['dispatch'] = useDispatch();
|
||||
const avatarFont = getAvatarFont(theme);
|
||||
const fontFamily = (avatarFont as any).fontFamily ?? 'Inter, sans-serif';
|
||||
const initialsColor = getAvatarInitialsColor(theme);
|
||||
const displayNameColor = getDisplayNameColor(theme);
|
||||
const shouldShowAvatar = shouldShowPiPAvatar(videoTrack);
|
||||
|
||||
const { canvasStreamRef, publishFrame } = useCanvasAvatar({
|
||||
participant,
|
||||
displayName,
|
||||
customAvatarBackgrounds,
|
||||
backgroundColor: getThumbnailBackgroundColor(theme),
|
||||
fontFamily,
|
||||
initialsColor,
|
||||
displayNameColor,
|
||||
shouldShowAvatar
|
||||
});
|
||||
|
||||
/**
|
||||
* Effect: Handle switching between real video track and canvas avatar stream.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTrack = previousTrackRef.current;
|
||||
let republishInterval: number | undefined;
|
||||
|
||||
// Detach previous track.
|
||||
if (previousTrack?.jitsiTrack) {
|
||||
try {
|
||||
previousTrack.jitsiTrack.detach(videoElement);
|
||||
} catch (error) {
|
||||
logger.error('Error detaching previous track:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowAvatar) {
|
||||
// Use canvas stream for avatar.
|
||||
// Access ref inside effect - stream is created in useCanvasAvatar's effect.
|
||||
const canvasStream = canvasStreamRef.current;
|
||||
|
||||
// Only set srcObject if it's different to avoid interrupting playback.
|
||||
if (canvasStream && videoElement.srcObject !== canvasStream) {
|
||||
videoElement.srcObject = canvasStream;
|
||||
publishFrame();
|
||||
|
||||
// Safari wires the element to a new srcObject asynchronously and silently drops canvas
|
||||
// frames published before that completes: streams never buffer for late sinks (W3C
|
||||
// resolution in w3c/mediacapture-transform#114) and the media-provider fetch runs
|
||||
// "in parallel" per the HTML spec, so no readiness event exists — republishing until
|
||||
// the element itself reports data is the only reliable strategy. Chromium captures
|
||||
// synchronously on requestFrame() and redelivers the current frame to late sinks,
|
||||
// so it needs no retry.
|
||||
if (browser.isWebKitBased()) {
|
||||
let attempts = 0;
|
||||
|
||||
republishInterval = window.setInterval(() => {
|
||||
const done = videoElement.readyState >= videoElement.HAVE_CURRENT_DATA
|
||||
|| videoElement.srcObject !== canvasStream;
|
||||
|
||||
if (done || ++attempts > WEBKIT_REPUBLISH_MAX_ATTEMPTS) {
|
||||
window.clearInterval(republishInterval);
|
||||
republishInterval = undefined;
|
||||
|
||||
if (!done) {
|
||||
logger.warn('Canvas stream produced no data after repeated frame requests');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
publishFrame();
|
||||
}, WEBKIT_REPUBLISH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
} else if (videoTrack?.jitsiTrack) {
|
||||
// Attach real video track.
|
||||
videoTrack.jitsiTrack.attach(videoElement)
|
||||
.catch((error: Error) => {
|
||||
logger.error('Error attaching video track:', error);
|
||||
});
|
||||
}
|
||||
|
||||
previousTrackRef.current = videoTrack;
|
||||
|
||||
// Cleanup on unmount or track change.
|
||||
return () => {
|
||||
if (republishInterval) {
|
||||
window.clearInterval(republishInterval);
|
||||
}
|
||||
if (videoTrack?.jitsiTrack && videoElement) {
|
||||
try {
|
||||
videoTrack.jitsiTrack.detach(videoElement);
|
||||
} catch (error) {
|
||||
logger.error('Error during cleanup:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [ videoTrack, shouldShowAvatar, publishFrame ]);
|
||||
|
||||
/**
|
||||
* Effect: Use WebKit presentation modes to enter and leave Video PiP on tab switches.
|
||||
*
|
||||
*/
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current as IWebKitPictureInPictureVideoElement | null;
|
||||
|
||||
if (!videoElement
|
||||
|| !browser.isWebKitBased()
|
||||
|| typeof videoElement.webkitSupportsPresentationMode !== 'function'
|
||||
|| typeof videoElement.webkitSetPresentationMode !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
let enteringWebKitPiP = false;
|
||||
let isPiPActive = false;
|
||||
|
||||
const resumeWebKitPiPPlayback = () => {
|
||||
if (videoElement.webkitPresentationMode !== 'picture-in-picture'
|
||||
|| !videoElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A muted live-MediaStream mirror has no meaningful paused state: any pause while in PiP
|
||||
// (Safari's hidden-tab interruption churn around the PiP transition, media keys) just
|
||||
// freezes the PiP frame, so playback is always resumed.
|
||||
videoElement.play()
|
||||
.catch(error => logger.warn('Failed to resume WebKit Picture-in-Picture video:', error));
|
||||
};
|
||||
const onEnterPiP = () => {
|
||||
if (!isPiPActive) {
|
||||
isPiPActive = true;
|
||||
dispatch(handlePipEnterEvent());
|
||||
}
|
||||
};
|
||||
const onLeavePiP = () => {
|
||||
if (!isPiPActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPiPActive = false;
|
||||
dispatch(handlePiPLeaveEvent());
|
||||
webKitPiPDismissedWhileHiddenRef.current = document.hidden;
|
||||
|
||||
};
|
||||
const enterWebKitPiP = async () => {
|
||||
if (enteringWebKitPiP || videoElement.webkitPresentationMode === 'picture-in-picture') {
|
||||
return;
|
||||
}
|
||||
enteringWebKitPiP = true;
|
||||
|
||||
try {
|
||||
if (videoElement.paused) {
|
||||
// Safari pauses hidden MediaStream videos, but a rejected play() (autoplay policy)
|
||||
// must not prevent the PiP entry attempt — WebKit does the final eligibility check
|
||||
// and the playback recovery below resumes playback once PiP is entered.
|
||||
await videoElement.play()
|
||||
.catch(error => logger.warn('Failed to play video before entering WebKit PiP:', error));
|
||||
}
|
||||
|
||||
// The tab may have become visible or PiP may have been dismissed while play() was pending.
|
||||
if (!document.hidden || webKitPiPDismissedWhileHiddenRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCurrentMedia = !videoElement.ended
|
||||
&& videoElement.readyState >= videoElement.HAVE_CURRENT_DATA;
|
||||
|
||||
if (hasCurrentMedia
|
||||
&& videoElement.webkitSupportsPresentationMode?.('picture-in-picture')) {
|
||||
videoElement.webkitSetPresentationMode?.('picture-in-picture');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to enter WebKit Picture-in-Picture:', error);
|
||||
} finally {
|
||||
enteringWebKitPiP = false;
|
||||
}
|
||||
};
|
||||
const exitWebKitPiP = () => {
|
||||
if (videoElement.webkitPresentationMode !== 'picture-in-picture') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
videoElement.webkitSetPresentationMode?.('inline');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to exit WebKit Picture-in-Picture:', error);
|
||||
}
|
||||
};
|
||||
const onVisibilityChange = (event: Event) => {
|
||||
if (!event.isTrusted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.hidden) {
|
||||
if (!webKitPiPDismissedWhileHiddenRef.current) {
|
||||
void enterWebKitPiP();
|
||||
}
|
||||
} else {
|
||||
webKitPiPDismissedWhileHiddenRef.current = false;
|
||||
exitWebKitPiP();
|
||||
}
|
||||
};
|
||||
const onPlaying = (event: Event) => {
|
||||
if (event.isTrusted && document.hidden && !webKitPiPDismissedWhileHiddenRef.current) {
|
||||
void enterWebKitPiP();
|
||||
}
|
||||
};
|
||||
const onWebKitPresentationModeChanged = () => {
|
||||
if (videoElement.webkitPresentationMode === 'picture-in-picture') {
|
||||
resumeWebKitPiPPlayback();
|
||||
onEnterPiP();
|
||||
} else {
|
||||
onLeavePiP();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
videoElement.addEventListener('pause', resumeWebKitPiPPlayback);
|
||||
videoElement.addEventListener('playing', onPlaying);
|
||||
videoElement.addEventListener('webkitpresentationmodechanged', onWebKitPresentationModeChanged);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
videoElement.removeEventListener('pause', resumeWebKitPiPPlayback);
|
||||
videoElement.removeEventListener('playing', onPlaying);
|
||||
videoElement.removeEventListener('webkitpresentationmodechanged', onWebKitPresentationModeChanged);
|
||||
exitWebKitPiP();
|
||||
};
|
||||
}, [ dispatch ]);
|
||||
|
||||
/**
|
||||
* Effect: Electron-only window blur/focus and visibility change listeners.
|
||||
* Enters PiP on blur and exits on focus, matching the old AOT behavior.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
if (!videoElement || !browser.isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onWindowBlur = () => dispatch(handleWindowBlur(videoElement));
|
||||
const onWindowFocus = () => {
|
||||
|
||||
// In the use case where the PiP is closed by the 'X' or 'back to main window' buttons, this handler is
|
||||
// called before the leavepictureinpicture handler. From there we call document.exitPictureInPicture()
|
||||
// which seems to put Chrome into a weird state - document.exitPictureInPicture() never resolves, the
|
||||
// leavepictureinpicture is never triggered and it is not possible to display PiP again.
|
||||
// This is probably a browser bug. To workaround it we have the 100ms timeout here. This way this event
|
||||
// is triggered after the leavepictureinpicture event and everything seems to work well.
|
||||
setTimeout(() => {
|
||||
dispatch(handleWindowFocus());
|
||||
}, FOCUS_CHECK_DELAY_MS);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
onWindowBlur();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
window.addEventListener('focus', onWindowFocus);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
// Check if window is already blurred on mount (handles PiP enable while app is in background).
|
||||
// Wait for video to be ready before attempting PiP (canvas stream may not be attached yet).
|
||||
const checkFocusAndEnterPiP = () => {
|
||||
if (!document.hasFocus()) {
|
||||
onWindowBlur();
|
||||
}
|
||||
};
|
||||
|
||||
if (videoElement.readyState >= 1) {
|
||||
// Video already has metadata loaded (e.g., real video track was already attached).
|
||||
checkFocusAndEnterPiP();
|
||||
} else {
|
||||
// Wait for video source to be ready (e.g., canvas stream being created).
|
||||
videoElement.addEventListener('loadedmetadata', checkFocusAndEnterPiP, { once: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
window.removeEventListener('focus', onWindowFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
videoElement.removeEventListener('loadedmetadata', checkFocusAndEnterPiP);
|
||||
};
|
||||
}, [ dispatch ]);
|
||||
|
||||
/**
|
||||
* Effect: PiP enter/leave event listeners.
|
||||
* Updates Redux state when browser PiP events occur.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
if (!videoElement || browser.isWebKitBased()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onEnterPiP = () => {
|
||||
dispatch(handlePipEnterEvent());
|
||||
};
|
||||
const onLeavePiP = () => {
|
||||
dispatch(handlePiPLeaveEvent());
|
||||
};
|
||||
|
||||
videoElement.addEventListener('enterpictureinpicture', onEnterPiP);
|
||||
videoElement.addEventListener('leavepictureinpicture', onLeavePiP);
|
||||
|
||||
return () => {
|
||||
videoElement.removeEventListener('enterpictureinpicture', onEnterPiP);
|
||||
videoElement.removeEventListener('leavepictureinpicture', onLeavePiP);
|
||||
};
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<video
|
||||
autoPlay = { true }
|
||||
className = { browser.isWebKitBased() ? classes.webKitVideo : classes.hiddenVideo }
|
||||
id = 'pipVideo'
|
||||
muted = { true }
|
||||
playsInline = { true }
|
||||
ref = { videoRef } />
|
||||
);
|
||||
};
|
||||
|
||||
export default PiPVideoElement;
|
||||
47
react/features/pip/components/web/DocumentPiPContent.tsx
Normal file
47
react/features/pip/components/web/DocumentPiPContent.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import createCache from '@emotion/cache';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import GlobalStyles from '../../../base/ui/components/GlobalStyles.web';
|
||||
|
||||
import { DocumentPiPView } from './DocumentPiPView';
|
||||
|
||||
/**
|
||||
* Inner component for the Document PiP.
|
||||
*
|
||||
* @returns {React.ReactElement | null}
|
||||
*/
|
||||
export function DocumentPiPContent() {
|
||||
const pipWindow = useSelector((state: IReduxState) => state['features/pip'].pipWindow);
|
||||
const pipCache = useMemo(() => {
|
||||
if (!pipWindow || pipWindow.closed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createCache({
|
||||
key: 'jitsi-pip',
|
||||
container: pipWindow.document.head
|
||||
});
|
||||
}, [ pipWindow ]);
|
||||
|
||||
useEffect(() => () => {
|
||||
pipCache?.sheet.flush();
|
||||
}, [ pipCache ]);
|
||||
|
||||
const pipRoot = pipWindow?.document.getElementById('pip-root');
|
||||
|
||||
if (!pipCache || !pipRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<CacheProvider value = { pipCache }>
|
||||
<GlobalStyles />
|
||||
<DocumentPiPView />
|
||||
</CacheProvider>,
|
||||
pipRoot
|
||||
);
|
||||
}
|
||||
180
react/features/pip/components/web/DocumentPiPView.tsx
Normal file
180
react/features/pip/components/web/DocumentPiPView.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import { getLocalParticipant, getParticipantDisplayName } from '../../../base/participants/functions';
|
||||
import DisplayNameBadge from '../../../display-name/components/web/DisplayNameBadge';
|
||||
import { getStageParticipantTypography } from '../../../display-name/components/web/styles';
|
||||
import { getLargeVideoParticipant } from '../../../large-video/functions';
|
||||
import { isPrejoinPageVisible } from '../../../prejoin/functions.any';
|
||||
import HangupButton from '../../../toolbox/components/HangupButton';
|
||||
import AudioMuteButton from '../../../toolbox/components/web/AudioMuteButton';
|
||||
import VideoMuteButton from '../../../toolbox/components/web/VideoMuteButton';
|
||||
import { getPiPVideoTrack, shouldShowPiPAvatar } from '../../functions';
|
||||
import logger from '../../logger';
|
||||
|
||||
const useStyles = makeStyles<void, 'controls'>()((theme, _params, classes) => {
|
||||
return {
|
||||
container: {
|
||||
backgroundColor: theme.palette.largeVideoBackground,
|
||||
color: theme.palette.text01,
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
width: '100vw',
|
||||
|
||||
'& *, & *::before, & *::after': {
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
},
|
||||
videoArea: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.largeVideoBackground,
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
|
||||
[`&:hover .${classes.controls}`]: {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto'
|
||||
}
|
||||
},
|
||||
videoElement: {
|
||||
backgroundColor: theme.palette.largeVideoBackground,
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
width: '100%'
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.largeVideoPlaceholder,
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
},
|
||||
floatingControls: {
|
||||
alignItems: 'center',
|
||||
bottom: theme.spacing(4),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
zIndex: 10
|
||||
},
|
||||
participantName: {
|
||||
...getStageParticipantTypography(theme),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
width: '100%'
|
||||
},
|
||||
controls: {
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.3s ease',
|
||||
|
||||
'& .toolbox-icon.hangup-button': {
|
||||
backgroundColor: theme.palette.actionDanger,
|
||||
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.actionDangerHover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.actionDangerActive
|
||||
}
|
||||
},
|
||||
|
||||
'& svg': {
|
||||
fill: theme.palette.icon01
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders the participant video, avatar fallback, display name and controls inside the Document PiP window.
|
||||
*
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export function DocumentPiPView() {
|
||||
const { classes, cx } = useStyles();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const isOnPrejoin = useSelector(isPrejoinPageVisible);
|
||||
const localParticipant = useSelector((state: IReduxState) => getLocalParticipant(state));
|
||||
const largeVideoParticipant = useSelector(getLargeVideoParticipant);
|
||||
const participant = isOnPrejoin ? localParticipant : largeVideoParticipant;
|
||||
const videoTrack = useSelector((state: IReduxState) => getPiPVideoTrack(state, participant));
|
||||
const participantName = useSelector((state: IReduxState) =>
|
||||
participant?.id ? getParticipantDisplayName(state, participant.id) : '');
|
||||
const shouldShowAvatar = shouldShowPiPAvatar(videoTrack);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
if (!video || shouldShowAvatar || !videoTrack?.jitsiTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoTrack.jitsiTrack.attach(video).catch((error: Error) => {
|
||||
logger.error('Failed to attach video track in Document PiP:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
try {
|
||||
videoTrack.jitsiTrack.detach(video);
|
||||
} catch (error) {
|
||||
logger.error('Failed to detach video track in Document PiP:', error);
|
||||
}
|
||||
};
|
||||
}, [ videoTrack, shouldShowAvatar ]);
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<div className = { classes.videoArea }>
|
||||
{shouldShowAvatar ? (
|
||||
<div className = { classes.avatarPlaceholder }>
|
||||
<Avatar
|
||||
participantId = { participant?.id }
|
||||
size = { 120 } />
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
autoPlay = { true }
|
||||
className = { classes.videoElement }
|
||||
muted = { true }
|
||||
playsInline = { true }
|
||||
ref = { videoRef } />
|
||||
)}
|
||||
<div className = { classes.floatingControls }>
|
||||
{participantName && <div className = { classes.participantName }>
|
||||
<DisplayNameBadge name = { participantName } />
|
||||
</div>}
|
||||
<div className = { cx(classes.controls, 'toolbox-content-items') }>
|
||||
<AudioMuteButton
|
||||
disableTooltip = { true }
|
||||
registerKeyboardShortcut = { false } />
|
||||
<VideoMuteButton
|
||||
disableTooltip = { true }
|
||||
registerKeyboardShortcut = { false } />
|
||||
<HangupButton
|
||||
customClass = 'hangup-button'
|
||||
disableTooltip = { true } />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
react/features/pip/components/web/PiPTriggerButton.tsx
Normal file
65
react/features/pip/components/web/PiPTriggerButton.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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';
|
||||
import { IconPip } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { togglePip } from '../../actions';
|
||||
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* Whether Picture-in-Picture is active.
|
||||
*/
|
||||
_isPiPActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens or closes Picture-in-Picture using the implementation supported by the browser.
|
||||
*/
|
||||
class PiPTriggerButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.pip';
|
||||
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.pipClose';
|
||||
override label = 'toolbar.pip';
|
||||
override toggledLabel = 'toolbar.pipClose';
|
||||
override tooltip = 'toolbar.pip';
|
||||
override toggledTooltip = 'toolbar.pipClose';
|
||||
override icon = IconPip;
|
||||
|
||||
/**
|
||||
* Indicates whether the button is toggled.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled(): boolean {
|
||||
return Boolean(this.props._isPiPActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles toggling Picture-in-Picture.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent('toggle.pip', { enable: !this._isToggled() }));
|
||||
dispatch(togglePip());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Redux state to component props.
|
||||
*
|
||||
* @param {IReduxState} state - Redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_isPiPActive: Boolean(state['features/pip']?.isPiPActive)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(PiPTriggerButton));
|
||||
9
react/features/pip/constants.ts
Normal file
9
react/features/pip/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Default WIDTH for the Document Picture-in-Picture window.
|
||||
*/
|
||||
export const DEFAULT_DOCUMENT_PIP_WIDTH = 284;
|
||||
|
||||
/**
|
||||
* Default HEIGHT for the Document Picture-in-Picture window.
|
||||
*/
|
||||
export const DEFAULT_DOCUMENT_PIP_HEIGHT = 160;
|
||||
48
react/features/pip/external-api.shared.ts
Normal file
48
react/features/pip/external-api.shared.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared utilities for PiP feature used by external_api.js.
|
||||
*
|
||||
* IMPORTANT: Keep this file minimal with no heavy dependencies.
|
||||
* It's bundled into external_api.min.js and we want to keep that bundle slim.
|
||||
* Only import lightweight modules here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if current environment is Electron.
|
||||
* Inline check to avoid importing BrowserDetection and its ua-parser dependency.
|
||||
*
|
||||
* @returns {boolean} - True if running in Electron.
|
||||
*/
|
||||
function isElectron(): boolean {
|
||||
return navigator.userAgent.includes('Electron');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if PiP is enabled based on config and environment.
|
||||
*
|
||||
* Honors the config it is given, but when `enableBrowserPiP` is absent it deliberately stays
|
||||
* permissive: the external API evaluates this against only the embedder-provided config, never
|
||||
* the deployment's server-side config, so applying the opt-in default here would silently
|
||||
* disable embedded auto-PiP for deployments that opt in server-side. The authoritative opt-in
|
||||
* default (an absent flag counts as disabled) is applied by `shouldShowPiP()` inside the client,
|
||||
* where the merged config is available — an over-approximating embedder only costs a no-op
|
||||
* show-PiP request that the client refuses.
|
||||
*
|
||||
* @param {Object} pipConfig - The pip config object.
|
||||
* @returns {boolean} - True if PiP is enabled.
|
||||
*/
|
||||
export function isPiPEnabled(pipConfig?: { disabled?: boolean; enableBrowserPiP?: boolean; }): boolean {
|
||||
if (pipConfig?.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pipConfig?.enableBrowserPiP === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'documentPictureInPicture' in window
|
||||
|| Boolean(document.pictureInPictureEnabled);
|
||||
}
|
||||
679
react/features/pip/functions.ts
Normal file
679
react/features/pip/functions.ts
Normal file
@@ -0,0 +1,679 @@
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { AVATAR_DEFAULT_BACKGROUND_COLOR } from '../base/avatar/components/web/styles';
|
||||
import { getAvatarColor, getInitials } from '../base/avatar/functions';
|
||||
import { leaveConference } from '../base/conference/actions';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { IParticipant } from '../base/participants/types';
|
||||
import { getLocalVideoTrack } from '../base/tracks/functions.any';
|
||||
import { getVideoTrackByParticipant } from '../base/tracks/functions.web';
|
||||
import { ITrack } from '../base/tracks/types';
|
||||
import { isTrackStreamingStatusActive } from '../connection-indicator/functions';
|
||||
import { isPrejoinPageVisible } from '../prejoin/functions.any';
|
||||
|
||||
import { toggleAudioFromPiP, toggleVideoFromPiP } from './actions';
|
||||
import { isPiPEnabled } from './external-api.shared';
|
||||
import logger from './logger';
|
||||
import {
|
||||
ExtendedMediaSessionAction,
|
||||
ExtendedMediaSessionActionHandler,
|
||||
IMediaSessionState
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Flag to track if a PiP request is currently pending (requested but not yet entered).
|
||||
*
|
||||
* 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 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.
|
||||
*/
|
||||
let pipRequestPending = false;
|
||||
|
||||
/**
|
||||
* Flag to track if a Document PiP request is currently pending.
|
||||
* Prevents duplicate requestWindow() calls before the first one resolves.
|
||||
*/
|
||||
let docPiPRequestPending = false;
|
||||
|
||||
/**
|
||||
* Returns whether a Document PiP request is currently pending.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDocumentPiPRequestPending() {
|
||||
return docPiPRequestPending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the pending state of the Document PiP request.
|
||||
*
|
||||
* @param {boolean} pending - Whether a Document PiP request is pending.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setDocumentPiPRequestPending(pending: boolean) {
|
||||
docPiPRequestPending = pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate video track for PiP based on prejoin state.
|
||||
* During prejoin, returns local video track. In conference, returns large video participant's track.
|
||||
*
|
||||
* @param {IReduxState} state - Redux state.
|
||||
* @param {IParticipant | undefined} participant - Participant to get track for.
|
||||
* @returns {ITrack | undefined} The video track or undefined.
|
||||
*/
|
||||
export function getPiPVideoTrack(state: IReduxState, participant: IParticipant | undefined) {
|
||||
const isOnPrejoin = isPrejoinPageVisible(state);
|
||||
|
||||
return isOnPrejoin
|
||||
? getLocalVideoTrack(state['features/base/tracks'])
|
||||
: getVideoTrackByParticipant(state, participant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether PiP should show an avatar instead of the selected video track.
|
||||
*
|
||||
* @param {ITrack | undefined} videoTrack - The selected PiP video track.
|
||||
* @returns {boolean} Whether the avatar should be shown.
|
||||
*/
|
||||
export function shouldShowPiPAvatar(videoTrack: ITrack | undefined): boolean {
|
||||
return !videoTrack
|
||||
|| videoTrack.muted
|
||||
|| (!videoTrack.local && !isTrackStreamingStatusActive(videoTrack));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if PiP should be shown based on config and current app state.
|
||||
* Checks if PiP is enabled and handles prejoin page visibility.
|
||||
*
|
||||
* @param {IReduxState} state - Redux state.
|
||||
* @returns {boolean} Whether PiP should be shown.
|
||||
*/
|
||||
export function shouldShowPiP(state: IReduxState): boolean {
|
||||
const pipConfig = state['features/base/config'].pip;
|
||||
|
||||
// Covers the global kill switch (disabled), an explicit enableBrowserPiP: false and browser
|
||||
// API support.
|
||||
if (!isPiPEnabled(pipConfig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Browser PiP is opt-in: it stays disabled unless the deployment explicitly sets
|
||||
// enableBrowserPiP: true (Electron is unaffected). This authoritative default lives here
|
||||
// rather than in isPiPEnabled() because the external API evaluates isPiPEnabled() against
|
||||
// only the embedder-provided config, without the deployment's config.js — it must stay
|
||||
// permissive when the flag is absent (see isPiPEnabled()).
|
||||
if (!browser.isElectron() && pipConfig?.enableBrowserPiP !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check prejoin state.
|
||||
const isOnPrejoin = isPrejoinPageVisible(state);
|
||||
const showOnPrejoin = pipConfig?.showOnPrejoin ?? false;
|
||||
|
||||
// Don't show PiP on prejoin unless explicitly enabled.
|
||||
if (isOnPrejoin && !showOnPrejoin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws an image-based avatar as a circular clipped image on canvas.
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
|
||||
* @param {string} imageUrl - URL of the avatar image.
|
||||
* @param {boolean | undefined} useCORS - Whether to use CORS for image loading.
|
||||
* @param {number} centerX - X coordinate of avatar center.
|
||||
* @param {number} centerY - Y coordinate of avatar center.
|
||||
* @param {number} radius - Radius of the avatar circle.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function drawImageAvatar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
imageUrl: string,
|
||||
useCORS: boolean | undefined,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number
|
||||
): Promise<void> {
|
||||
const img = new Image();
|
||||
|
||||
if (useCORS) {
|
||||
img.crossOrigin = 'anonymous';
|
||||
}
|
||||
img.src = imageUrl;
|
||||
|
||||
try {
|
||||
await img.decode();
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
const size = radius * 2;
|
||||
|
||||
ctx.drawImage(img, centerX - radius, centerY - radius, size, size);
|
||||
ctx.restore();
|
||||
} catch (error) {
|
||||
logger.error('Failed to draw image avatar', error);
|
||||
throw new Error('Image load failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws an initials-based avatar with a colored background on canvas.
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
|
||||
* @param {string} name - Participant's display name.
|
||||
* @param {Array<string>} customAvatarBackgrounds - Custom avatar background colors.
|
||||
* @param {number} centerX - X coordinate of avatar center.
|
||||
* @param {number} centerY - Y coordinate of avatar center.
|
||||
* @param {number} radius - Radius of the avatar circle.
|
||||
* @param {string} fontFamily - Font family to use for initials.
|
||||
* @param {string} textColor - Color for the initials text.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function drawInitialsAvatar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
name: string,
|
||||
customAvatarBackgrounds: Array<string>,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
fontFamily: string,
|
||||
textColor: string
|
||||
) {
|
||||
const initials = getInitials(name);
|
||||
const color = getAvatarColor(name, customAvatarBackgrounds);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `bold 80px ${fontFamily}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(initials, centerX, centerY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the default user icon when no avatar is available.
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
|
||||
* @param {HTMLImageElement | null} defaultIcon - Preloaded default icon image.
|
||||
* @param {number} centerX - X coordinate of icon center.
|
||||
* @param {number} centerY - Y coordinate of icon center.
|
||||
* @param {number} radius - Radius of the icon circle.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function drawDefaultIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
defaultIcon: HTMLImageElement | null,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number
|
||||
) {
|
||||
ctx.fillStyle = AVATAR_DEFAULT_BACKGROUND_COLOR;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
if (defaultIcon) {
|
||||
const iconSize = radius;
|
||||
const x = centerX - iconSize / 2;
|
||||
const y = centerY - iconSize / 2;
|
||||
|
||||
ctx.drawImage(defaultIcon, x, y, iconSize, iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum character limit for display name before truncation.
|
||||
*/
|
||||
const DISPLAY_NAME_MAX_CHARS = 25;
|
||||
|
||||
/**
|
||||
* Draws the participant's display name below the avatar.
|
||||
* Truncates long names with ellipsis using a simple character limit.
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
|
||||
* @param {string} displayName - Participant's display name.
|
||||
* @param {number} centerX - X coordinate of text center.
|
||||
* @param {number} y - Y coordinate of text top.
|
||||
* @param {string} fontFamily - Font family to use for display name.
|
||||
* @param {string} textColor - Color for the display name text.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function drawDisplayName(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
displayName: string,
|
||||
centerX: number,
|
||||
y: number,
|
||||
fontFamily: string,
|
||||
textColor: string
|
||||
) {
|
||||
const truncated = displayName.length > DISPLAY_NAME_MAX_CHARS
|
||||
? `${displayName.slice(0, DISPLAY_NAME_MAX_CHARS)}...`
|
||||
: displayName;
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `24px ${fontFamily}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(truncated, centerX, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a complete avatar (image, initials, or default icon) with display name on canvas.
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas - The canvas element.
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
|
||||
* @param {IParticipant | undefined} participant - The participant to render.
|
||||
* @param {string} displayName - The display name to show.
|
||||
* @param {Array<string>} customAvatarBackgrounds - Custom avatar background colors.
|
||||
* @param {HTMLImageElement | null} defaultIcon - Preloaded default icon image.
|
||||
* @param {string} backgroundColor - Background color for the canvas.
|
||||
* @param {string} fontFamily - Font family to use for text rendering.
|
||||
* @param {string} initialsColor - Color for avatar initials text.
|
||||
* @param {string} displayNameColor - Color for display name text.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function renderAvatarOnCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
participant: IParticipant | undefined,
|
||||
displayName: string,
|
||||
customAvatarBackgrounds: Array<string>,
|
||||
defaultIcon: HTMLImageElement | null,
|
||||
backgroundColor: string,
|
||||
fontFamily: string,
|
||||
initialsColor: string,
|
||||
displayNameColor: string
|
||||
): Promise<void> {
|
||||
const { width, height } = canvas;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const avatarRadius = 100;
|
||||
const spacing = 20;
|
||||
const textY = centerY + avatarRadius + spacing;
|
||||
|
||||
// Clear and fill background.
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
let avatarRendered = false;
|
||||
|
||||
if (participant?.loadableAvatarUrl) {
|
||||
try {
|
||||
await drawImageAvatar(
|
||||
ctx,
|
||||
participant.loadableAvatarUrl,
|
||||
participant.loadableAvatarUrlUseCORS,
|
||||
centerX,
|
||||
centerY,
|
||||
avatarRadius
|
||||
);
|
||||
avatarRendered = true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load image avatar, falling back.', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatarRendered) {
|
||||
if (participant?.name) {
|
||||
drawInitialsAvatar(
|
||||
ctx, participant.name, customAvatarBackgrounds, centerX, centerY, avatarRadius, fontFamily, initialsColor
|
||||
);
|
||||
} else {
|
||||
drawDefaultIcon(ctx, defaultIcon, centerX, centerY, avatarRadius);
|
||||
}
|
||||
}
|
||||
|
||||
drawDisplayName(ctx, displayName, centerX, textY, fontFamily, displayNameColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests picture-in-picture mode for the pip video element.
|
||||
*
|
||||
* NOTE: Called by Electron main process with userGesture: true.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function requestPictureInPicture() {
|
||||
const video = document.getElementById('pipVideo') as HTMLVideoElement;
|
||||
|
||||
if (!video) {
|
||||
logger.error('PiP video element (#pipVideo) not found');
|
||||
pipRequestPending = false;
|
||||
|
||||
return;
|
||||
}
|
||||
if (document.pictureInPictureElement) {
|
||||
logger.warn('Already in PiP mode');
|
||||
pipRequestPending = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if video metadata is loaded.
|
||||
// readyState >= 1 (HAVE_METADATA) means video dimensions are available.
|
||||
if (video.readyState < 1) {
|
||||
logger.warn('Video metadata not loaded yet, waiting...');
|
||||
|
||||
// Two known limitations of this deferred request, deliberately not handled for now:
|
||||
//
|
||||
// 1. 'loadedmetadata' may never fire (e.g. the avatar image fetch stalls forever, so the
|
||||
// canvas captureStream(0) never produces a frame, or a video track never delivers a
|
||||
// frame). pipRequestPending then stays true and PiP is blocked until page reload.
|
||||
// If reports of a permanently dead PiP button ever come in, bound the wait:
|
||||
//
|
||||
// const onMetadata = () => {
|
||||
// clearTimeout(timer);
|
||||
// // ...existing requestPictureInPicture() logic...
|
||||
// };
|
||||
// const timer = setTimeout(() => {
|
||||
// video.removeEventListener('loadedmetadata', onMetadata);
|
||||
// logger.warn('Timed out waiting for video metadata, resetting PiP request state.');
|
||||
// pipRequestPending = false;
|
||||
// }, 5000);
|
||||
// video.addEventListener('loadedmetadata', onMetadata, { once: true });
|
||||
//
|
||||
// 2. TODO: In browsers, by the time 'loadedmetadata' fires the click's transient activation
|
||||
// has expired, so requestPictureInPicture() below rejects with NotAllowedError and the
|
||||
// click fails silently; clicking again once media is flowing works. This only happens
|
||||
// when the click lands in a readyState < 1 window: the brief moment right after mount,
|
||||
// the ~100ms srcObject swap when the large-video participant changes or the camera is
|
||||
// toggled, or while a slow avatar image fetch delays the first canvas frame — so it is
|
||||
// rare in practice. Revisit if it shows up in reports (e.g. a short bounded wait within
|
||||
// the activation window). The Electron flow is unaffected: it requests PiP from the main
|
||||
// process with userGesture: true, and its behavior here is pre-existing.
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
logger.debug(`Calling video.requestPictureInPicture(), readyState=${video.readyState}`);
|
||||
|
||||
video.requestPictureInPicture().then(() => {
|
||||
logger.debug('video.requestPictureInPicture() succeeded');
|
||||
}).catch((err: Error) => {
|
||||
logger.error(`Error while requesting PiP after metadata loaded: ${err.message}`);
|
||||
}).finally(() => {
|
||||
// Currently Electron will only pass the requests and execute requestPictureInPicture but
|
||||
// if the code there becomes more complicated it is worth considering to change the implementation
|
||||
// to handle errors on the Electron side to prevent the scenario where the code in Electron fails
|
||||
// and the this flag is not reset. This would prevent PiP for ever displaying again.
|
||||
pipRequestPending = false;
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Calling video.requestPictureInPicture(), readyState=${video.readyState}`);
|
||||
|
||||
video.requestPictureInPicture().then(() => {
|
||||
logger.debug('video.requestPictureInPicture() succeeded');
|
||||
}).catch((err: Error) => {
|
||||
logger.error(`Error while requesting PiP: ${err.message}`);
|
||||
}).finally(() => {
|
||||
// Currently Electron will only pass the requests and execute requestPictureInPicture but
|
||||
// if the code there becomes more complicated it is worth considering to change the implementation
|
||||
// to handle errors on the Electron side to prevent the scenario where the code in Electron fails
|
||||
// and the this flag is not reset. This would prevent PiP for ever displaying again.
|
||||
pipRequestPending = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to enter Picture-in-Picture mode.
|
||||
* Handles both browser and Electron environments.
|
||||
*
|
||||
* @param {HTMLVideoElement} videoElement - The video element to call requestPictureInPicuture on.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function enterVideoPiP(videoElement: HTMLVideoElement | undefined | null) {
|
||||
if (!videoElement) {
|
||||
logger.error('PiP video element not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a PiP request is already pending or PiP is already active.
|
||||
if (pipRequestPending) {
|
||||
logger.debug('PiP request already pending, skipping duplicate request');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.pictureInPictureElement) {
|
||||
logger.debug('PiP already active, skipping request');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if PiP is supported.
|
||||
if (!('pictureInPictureEnabled' in document)) {
|
||||
logger.error('Picture-in-Picture is not supported in this browser');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.pictureInPictureEnabled === false) {
|
||||
logger.error('Picture-in-Picture is disabled');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// In Electron, use postMessage to request PiP from main process.
|
||||
// This bypasses the transient activation requirement by executing
|
||||
// requestPictureInPicture with userGesture: true in the main process.
|
||||
if (browser.isElectron()) {
|
||||
logger.log('Electron detected, sending postMessage to request PiP');
|
||||
pipRequestPending = true;
|
||||
|
||||
APP.API.notifyPictureInPictureRequested();
|
||||
|
||||
// State will be updated by enterpictureinpicture event.
|
||||
return;
|
||||
}
|
||||
|
||||
// In browsers, directly request Video PiP.
|
||||
pipRequestPending = true;
|
||||
requestPictureInPicture();
|
||||
} catch (error) {
|
||||
pipRequestPending = false;
|
||||
logger.error('Error entering Picture-in-Picture:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an extended MediaSession action handler when supported by the browser.
|
||||
*
|
||||
* @param {ExtendedMediaSessionAction} action - The MediaSession action to configure.
|
||||
* @param {ExtendedMediaSessionActionHandler | null} handler - The action handler, or null to clear it.
|
||||
* @returns {void}
|
||||
*/
|
||||
function setExtendedMediaSessionActionHandler(
|
||||
action: ExtendedMediaSessionAction,
|
||||
handler: ExtendedMediaSessionActionHandler | null) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, handler);
|
||||
} catch (error) {
|
||||
logger.debug(`MediaSession action '${action}' is not supported:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up MediaSession API action handlers for controlling the conference.
|
||||
* Handlers dispatch actions that query fresh Redux state, avoiding stale closures.
|
||||
*
|
||||
* @param {Function} dispatch - Redux dispatch function.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setupMediaSessionHandlers(dispatch: IStore['dispatch']) {
|
||||
if ('mediaSession' in navigator && typeof navigator.mediaSession?.setActionHandler === 'function') {
|
||||
// Set up audio mute toggle handler.
|
||||
// Dispatch action that will query current state and toggle.
|
||||
setExtendedMediaSessionActionHandler('togglemicrophone', () => {
|
||||
dispatch(toggleAudioFromPiP());
|
||||
});
|
||||
|
||||
// Set up video mute toggle handler.
|
||||
// Dispatch action that will query current state and toggle.
|
||||
setExtendedMediaSessionActionHandler('togglecamera', () => {
|
||||
dispatch(toggleVideoFromPiP());
|
||||
});
|
||||
|
||||
// Set up hangup handler.
|
||||
setExtendedMediaSessionActionHandler('hangup', () => {
|
||||
dispatch(leaveConference());
|
||||
});
|
||||
|
||||
logger.log('MediaSession API handlers registered for supported PiP controls');
|
||||
} else {
|
||||
logger.warn('MediaSession API not supported in this browser');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the MediaSession API microphone and camera active state.
|
||||
* This ensures the PiP controls show the correct mute/unmute state.
|
||||
*
|
||||
* @param {IMediaSessionState} state - The current media session state.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function updateMediaSessionState(state: IMediaSessionState) {
|
||||
// Safari requires user activation when setting MediaSession capture state.
|
||||
// This runs from a Redux subscriber outside the initiating user gesture,
|
||||
// so Safari rejects the request. Skip state synchronization for Safari.
|
||||
// observed behavior in v26.5.2
|
||||
if (browser.isSafari()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
const mediaSession = navigator.mediaSession as MediaSession & {
|
||||
setCameraActive?: (active: boolean) => void;
|
||||
setMicrophoneActive?: (active: boolean) => void;
|
||||
};
|
||||
|
||||
try {
|
||||
if (mediaSession.setMicrophoneActive) {
|
||||
mediaSession.setMicrophoneActive(state.microphoneActive);
|
||||
}
|
||||
|
||||
if (mediaSession.setCameraActive) {
|
||||
mediaSession.setCameraActive(state.cameraActive);
|
||||
}
|
||||
|
||||
logger.log('MediaSession state updated:', state);
|
||||
} catch (error) {
|
||||
logger.warn('Error updating MediaSession state:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up MediaSession API action handlers.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function cleanupMediaSessionHandlers() {
|
||||
if ('mediaSession' in navigator) {
|
||||
setExtendedMediaSessionActionHandler('togglemicrophone', null);
|
||||
setExtendedMediaSessionActionHandler('togglecamera', null);
|
||||
setExtendedMediaSessionActionHandler('hangup', null);
|
||||
logger.log('Supported MediaSession API handlers cleaned up');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies initial stylings of the main window to PiP window.
|
||||
* Creates the container for the PiP window.
|
||||
*
|
||||
* The reference to the opened window lives in the pip Redux state (see setPiPWindow), not here,
|
||||
* so that every change to it is observable by React.
|
||||
*
|
||||
* @param {Window} pipWindow - Current window.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function initPiPWindow(pipWindow: Window) {
|
||||
copyStylesheets(pipWindow);
|
||||
createPiPContainer(pipWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies stylesheet links and inline styles 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}
|
||||
*/
|
||||
function copyStylesheets(pipWindow: Window) {
|
||||
const { document: pipDoc } = pipWindow;
|
||||
|
||||
document.head.querySelectorAll('link[rel="stylesheet"], style').forEach(node => {
|
||||
try {
|
||||
if (node instanceof HTMLStyleElement) {
|
||||
pipDoc.head.appendChild(pipDoc.importNode(node, true));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(node instanceof HTMLLinkElement) || !node.href) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = pipDoc.createElement('link');
|
||||
|
||||
Array.from(node.attributes).forEach(attribute => {
|
||||
if (attribute.name !== 'href' && (attribute.name !== 'type' || attribute.value)) {
|
||||
link.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
});
|
||||
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 pipDoc = pipWindow.document;
|
||||
|
||||
// The copied stylesheets load asynchronously; until they land, the UA default body margin
|
||||
// and overflow would make the 100vw/100vh container overflow and flash scrollbars on every
|
||||
// open, so reset them synchronously.
|
||||
pipDoc.body.style.margin = '0';
|
||||
pipDoc.body.style.overflow = 'hidden';
|
||||
|
||||
const container = pipDoc.createElement('div');
|
||||
|
||||
container.id = 'pip-root';
|
||||
container.style.cssText = 'margin: 0; padding: 0; overflow: hidden; height: 100vh; width: 100vw;';
|
||||
pipDoc.body.appendChild(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Document Picture-in-Picture API is supported.
|
||||
* Keeps Electron on its existing Video PiP implementation.
|
||||
*
|
||||
* @returns {boolean} True if Document PiP is supported.
|
||||
*/
|
||||
export function isDocumentPiPSupported(): boolean {
|
||||
return !browser.isElectron() && 'documentPictureInPicture' in window;
|
||||
}
|
||||
|
||||
// Re-export from shared file for external use.
|
||||
export { isPiPEnabled };
|
||||
330
react/features/pip/hooks.ts
Normal file
330
react/features/pip/hooks.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import IconUserSVG from '../base/icons/svg/user.svg?raw';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { IParticipant } from '../base/participants/types';
|
||||
import { TILE_ASPECT_RATIO } from '../filmstrip/constants';
|
||||
|
||||
import { hidePiP, openDocumentPiP } from './actions';
|
||||
import PiPTriggerButton from './components/web/PiPTriggerButton';
|
||||
import {
|
||||
isDocumentPiPSupported,
|
||||
renderAvatarOnCanvas,
|
||||
shouldShowPiP,
|
||||
} from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Canvas dimensions for PiP avatar rendering.
|
||||
*/
|
||||
const CANVAS_WIDTH = 640;
|
||||
const CANVAS_HEIGHT = Math.floor(CANVAS_WIDTH / TILE_ASPECT_RATIO);
|
||||
|
||||
/**
|
||||
* Frame rate 0 means capture on-demand when canvas changes.
|
||||
* We manually request frames after drawing to ensure capture.
|
||||
*/
|
||||
const CANVAS_FRAME_RATE = 0;
|
||||
|
||||
const togglePiP = {
|
||||
key: 'toggle-pip',
|
||||
Content: PiPTriggerButton,
|
||||
group: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for the useCanvasAvatar hook.
|
||||
*/
|
||||
interface IUseCanvasAvatarOptions {
|
||||
backgroundColor: string;
|
||||
customAvatarBackgrounds: string[];
|
||||
displayName: string;
|
||||
displayNameColor: string;
|
||||
fontFamily: string;
|
||||
initialsColor: string;
|
||||
participant: IParticipant | undefined;
|
||||
shouldShowAvatar: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by the useCanvasAvatar hook.
|
||||
* The stream ref is populated in an effect, while publishFrame lets consumers
|
||||
* explicitly deliver the current canvas after attaching it to a video element.
|
||||
*/
|
||||
interface IUseCanvasAvatarResult {
|
||||
canvasStreamRef: React.MutableRefObject<MediaStream | null>;
|
||||
publishFrame: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal refs managed by the hook.
|
||||
*/
|
||||
interface ICanvasRefs {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
defaultIcon: HTMLImageElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and prepares the default user icon SVG as an Image element.
|
||||
*
|
||||
* @returns {HTMLImageElement} The prepared image element.
|
||||
*/
|
||||
function createDefaultIconImage(): HTMLImageElement {
|
||||
let svgText = IconUserSVG;
|
||||
|
||||
if (!svgText.includes('fill=')) {
|
||||
svgText = svgText.replace('<svg', '<svg fill="#FFFFFF"');
|
||||
}
|
||||
|
||||
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svgText)
|
||||
.replace(/'/g, '%27')
|
||||
.replace(/"/g, '%22')}`;
|
||||
|
||||
const img = new Image();
|
||||
|
||||
img.src = dataUrl;
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook that manages canvas-based avatar rendering for Picture-in-Picture.
|
||||
* Creates and maintains a canvas element with a MediaStream that can be used
|
||||
* as a video source when the participant's video is unavailable. Avatar rendering
|
||||
* is gated by shouldShowAvatar, while hidden content is cleared to avoid stale identity frames.
|
||||
*
|
||||
* @param {IUseCanvasAvatarOptions} options - The hook options.
|
||||
* @returns {IUseCanvasAvatarResult} The canvas stream and frame publication callback.
|
||||
*/
|
||||
export function useCanvasAvatar(options: IUseCanvasAvatarOptions): IUseCanvasAvatarResult {
|
||||
const {
|
||||
participant,
|
||||
displayName,
|
||||
customAvatarBackgrounds,
|
||||
backgroundColor,
|
||||
fontFamily,
|
||||
initialsColor,
|
||||
displayNameColor,
|
||||
shouldShowAvatar
|
||||
} = options;
|
||||
|
||||
const refs = useRef<ICanvasRefs>({
|
||||
canvas: null,
|
||||
defaultIcon: null
|
||||
});
|
||||
|
||||
// Separate ref for the stream to return to consumers.
|
||||
// This allows consumers to access .current inside their effects.
|
||||
//
|
||||
// NOTE: If we ever need to recreate the stream (e.g., different canvas size),
|
||||
// consumers' effects won't automatically re-run since refs don't trigger re-renders.
|
||||
// To fix this, we could return an additional state flag like `streamReady` that
|
||||
// changes when the stream is set, and consumers would add it to their effect deps.
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
/**
|
||||
* Publishes the canvas's current contents to its on-demand capture stream.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const publishFrame = useCallback(() => {
|
||||
const track = streamRef.current?.getVideoTracks()[0] as MediaStreamTrack & { requestFrame?: () => void; };
|
||||
const canvas = refs.current.canvas;
|
||||
|
||||
if (!track?.requestFrame || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
track.requestFrame();
|
||||
|
||||
// WebKit emits a canvas-capture frame only when the canvas is painted AFTER requestFrame()
|
||||
// has armed the capture — a bare requestFrame() on a static canvas produces nothing and the
|
||||
// PiP video stays at readyState 0 (verified on Safari 26.6). Nudge the canvas with an
|
||||
// imperceptible 1px draw so an emission paint always follows the arm. Chromium captures
|
||||
// the current canvas contents immediately on requestFrame(), so it needs no nudge.
|
||||
if (browser.isWebKitBased()) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.01;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('Canvas frame requested');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize canvas, stream, and default icon on mount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Create canvas.
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = CANVAS_WIDTH;
|
||||
canvas.height = CANVAS_HEIGHT;
|
||||
refs.current.canvas = canvas;
|
||||
|
||||
// Create stream from canvas.
|
||||
streamRef.current = canvas.captureStream(CANVAS_FRAME_RATE);
|
||||
|
||||
// Load default icon.
|
||||
refs.current.defaultIcon = createDefaultIconImage();
|
||||
|
||||
logger.log('Canvas avatar initialized');
|
||||
|
||||
// Cleanup on unmount.
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
refs.current.canvas = null;
|
||||
refs.current.defaultIcon = null;
|
||||
logger.log('Canvas avatar cleaned up');
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Renders the avatar when it should be shown and clears stale participant content otherwise.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const { canvas, defaultIcon } = refs.current;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
logger.error('Failed to get canvas 2D context');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear participant identity synchronously before the canvas can be attached to a new sink.
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (!shouldShowAvatar) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderAvatarOnCanvas(
|
||||
canvas,
|
||||
ctx,
|
||||
participant,
|
||||
displayName,
|
||||
customAvatarBackgrounds,
|
||||
defaultIcon,
|
||||
backgroundColor,
|
||||
fontFamily,
|
||||
initialsColor,
|
||||
displayNameColor
|
||||
).then(publishFrame)
|
||||
.catch((error: Error) => logger.error('Error rendering avatar on canvas:', error));
|
||||
}, [ participant?.loadableAvatarUrl, participant?.loadableAvatarUrlUseCORS, participant?.name, displayName, customAvatarBackgrounds, backgroundColor, fontFamily, initialsColor, displayNameColor, shouldShowAvatar, publishFrame ]);
|
||||
|
||||
return {
|
||||
canvasStreamRef: streamRef,
|
||||
publishFrame
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Document Picture-in-Picture via the MediaSession API in browsers that support its
|
||||
* enterpictureinpicture action. WebKit automatic Video PiP on tab switches is managed separately by PiPVideoElement
|
||||
* through the presentation-mode API.
|
||||
* Closes the PiP window when the tab becomes visible again.
|
||||
*
|
||||
* MediaSession playback state is kept in sync by the subscriber in
|
||||
* subscriber.ts, so this hook only handles the window lifecycle.
|
||||
*
|
||||
* @see https://googlechrome.github.io/samples/media-session/video-conferencing.html
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function useDocumentPiPMediaSession() {
|
||||
const dispatch: IStore['dispatch'] = useDispatch();
|
||||
|
||||
const openDocumentPip = useCallback(
|
||||
(notifyOnFailure: boolean) => dispatch(openDocumentPiP({ notifyOnFailure })),
|
||||
[ dispatch ]
|
||||
);
|
||||
|
||||
const pipEnabled = useSelector(shouldShowPiP);
|
||||
const documentPiPAvailable = pipEnabled && isDocumentPiPSupported();
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentPiPAvailable
|
||||
|| !('mediaSession' in navigator)
|
||||
|| typeof navigator.mediaSession?.setActionHandler !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler('enterpictureinpicture', details => {
|
||||
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.');
|
||||
}
|
||||
|
||||
// The browser invokes this handler fire-and-forget, so nothing may leak a
|
||||
// rejection here. openDocumentPiP() reports every failure internally: it always
|
||||
// logs, and for user-initiated entries it also shows the same error notification
|
||||
// as the toolbar button; automatic entries stay silent to avoid nagging on every
|
||||
// tab switch.
|
||||
openDocumentPip(reason === 'useraction');
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('enterpictureinpicture MediaSession action not supported:', error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
navigator.mediaSession.setActionHandler('enterpictureinpicture', null);
|
||||
};
|
||||
}, [ documentPiPAvailable, openDocumentPip ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentPiPAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
// hidePiP() checks the current PiP state inside the thunk, so the handler needs no
|
||||
// state in its closure and the listener registers once for the lifetime of the mount.
|
||||
if (!document.hidden) {
|
||||
dispatch(hidePiP());
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [ dispatch, documentPiPAvailable ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PiP toggle button when PiP is enabled and supported by the browser.
|
||||
*
|
||||
* @returns {Object | undefined} The PiP toggle button or undefined.
|
||||
*/
|
||||
export function usePipToggleButton() {
|
||||
const visible = useSelector((state: IReduxState) =>
|
||||
state['features/base/config'].pip?.showToolbarButton !== false && shouldShowPiP(state));
|
||||
|
||||
return !browser.isElectron() && visible
|
||||
&& (isDocumentPiPSupported() || Boolean(document.pictureInPictureEnabled))
|
||||
? togglePiP
|
||||
: undefined;
|
||||
}
|
||||
3
react/features/pip/logger.ts
Normal file
3
react/features/pip/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('app:pip');
|
||||
119
react/features/pip/middleware.ts
Normal file
119
react/features/pip/middleware.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IReduxState, IStore } from '../app/types';
|
||||
import { CONFERENCE_FAILED, CONFERENCE_LEFT, CONFERENCE_WILL_LEAVE } from '../base/conference/actionTypes';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import MediaCastSender from '../base/media-cast/MediaCastSender';
|
||||
import type { MediaCastSignal } from '../base/media-cast/types.web';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { isEmbedded } from '../base/util/embedUtils';
|
||||
import { getLargeVideoParticipant } from '../large-video/functions';
|
||||
|
||||
import {
|
||||
HOST_DOCUMENT_PIP_CLOSED,
|
||||
HOST_DOCUMENT_PIP_OPENED,
|
||||
HOST_DOCUMENT_PIP_SIGNAL_RECEIVED
|
||||
} from './actionTypes';
|
||||
import { clearHostDocumentPiPPendingState, exitPiP } from './actions';
|
||||
import { getPiPVideoTrack, isDocumentPiPRequestPending, setDocumentPiPRequestPending } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
import './subscriber';
|
||||
|
||||
/**
|
||||
* Sender associated with the currently open host-owned Document PiP window.
|
||||
*/
|
||||
let mediaCastSender: MediaCastSender | undefined;
|
||||
|
||||
/**
|
||||
* Returns the live native video track selected for PiP without taking ownership of it.
|
||||
*
|
||||
* @param {IReduxState} state - Current Redux state.
|
||||
* @returns {MediaStreamTrack|null}
|
||||
*/
|
||||
function getNativePiPTrack(state: IReduxState): MediaStreamTrack | null {
|
||||
const videoTrack = getPiPVideoTrack(state, getLargeVideoParticipant(state));
|
||||
const nativeTrack = videoTrack?.jitsiTrack?.getTrack();
|
||||
|
||||
return videoTrack?.muted || nativeTrack?.readyState !== 'live' ? null : nativeTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops and releases the media-cast sender without stopping its conference-owned source track.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function stopSender() {
|
||||
mediaCastSender?.stop();
|
||||
mediaCastSender = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the media-cast sender or updates its current source track.
|
||||
*
|
||||
* @param {IStore} store - Redux store used to select the initial track.
|
||||
* @returns {void}
|
||||
*/
|
||||
function startSender(store: IStore) {
|
||||
if (mediaCastSender) {
|
||||
void mediaCastSender.setTrack(getNativePiPTrack(store.getState()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
mediaCastSender = new MediaCastSender({
|
||||
onError: error => logger.error('Embedded Document PiP media sender failed:', error),
|
||||
onSignal: signal => APP.API.notifyDocumentPiPSignal(signal)
|
||||
});
|
||||
mediaCastSender.start(getNativePiPTrack(store.getState()));
|
||||
}
|
||||
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ (state: IReduxState) => {
|
||||
const pipState = state['features/pip'];
|
||||
|
||||
return isEmbedded() && pipState?.isPiPActive
|
||||
? getNativePiPTrack(state)
|
||||
: undefined;
|
||||
},
|
||||
/* listener */ (track: MediaStreamTrack | null | undefined) => {
|
||||
if (track !== undefined) {
|
||||
mediaCastSender?.setTrack(track)
|
||||
.catch(error => logger.error('Failed to update embedded Document PiP track:', error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyAction) => {
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case HOST_DOCUMENT_PIP_OPENED:
|
||||
// Electron stays on Video PiP and never has a host receiver, so no sender is started.
|
||||
if (!browser.isElectron()) {
|
||||
startSender(store);
|
||||
}
|
||||
break;
|
||||
case HOST_DOCUMENT_PIP_CLOSED:
|
||||
stopSender();
|
||||
break;
|
||||
case HOST_DOCUMENT_PIP_SIGNAL_RECEIVED:
|
||||
mediaCastSender?.handleSignal(action.signal as MediaCastSignal);
|
||||
break;
|
||||
case CONFERENCE_WILL_LEAVE:
|
||||
case CONFERENCE_FAILED:
|
||||
case CONFERENCE_LEFT:
|
||||
clearHostDocumentPiPPendingState();
|
||||
if (store.getState()['features/pip']?.isPiPActive) {
|
||||
store.dispatch(exitPiP());
|
||||
} else if (isDocumentPiPRequestPending()) {
|
||||
setDocumentPiPRequestPending(false);
|
||||
APP.API.notifyDocumentPiPClose();
|
||||
}
|
||||
stopSender();
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
52
react/features/pip/reducer.ts
Normal file
52
react/features/pip/reducer.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import { SET_PIP_ACTIVE, SET_PIP_WINDOW } from './actionTypes';
|
||||
|
||||
/**
|
||||
* The default state for the pip feature.
|
||||
*/
|
||||
const DEFAULT_STATE = {
|
||||
isPiPActive: false,
|
||||
pipWindow: null
|
||||
};
|
||||
|
||||
export interface IPipState {
|
||||
isPiPActive: boolean;
|
||||
|
||||
/**
|
||||
* The Document PiP window this feature opened and initialized (stylesheets copied, #pip-root
|
||||
* container created), or null when none is open. This differs from
|
||||
* window.documentPictureInPicture.window, which is the browser's view of any Document PiP
|
||||
* window open for the page regardless of who opened it. Kept in Redux so that every change
|
||||
* to the reference is observable by React.
|
||||
*
|
||||
* The ts-ignore mirrors web-hid's HIDDevice: the pip feature is excluded from the native
|
||||
* TypeScript config, which has no DOM lib defining Window, but this interface still reaches
|
||||
* the native check through IReduxState. Types are erased at build time, so the suppression
|
||||
* only affects the type check and nothing on native ever reads this field.
|
||||
*/
|
||||
// @ts-ignore
|
||||
pipWindow: Window | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces the Redux actions of the pip feature.
|
||||
*/
|
||||
ReducerRegistry.register<IPipState>('features/pip', (state = DEFAULT_STATE, action): IPipState => {
|
||||
switch (action.type) {
|
||||
case SET_PIP_ACTIVE:
|
||||
return {
|
||||
...state,
|
||||
isPiPActive: action.isPiPActive
|
||||
};
|
||||
|
||||
case SET_PIP_WINDOW:
|
||||
return {
|
||||
...state,
|
||||
pipWindow: action.pipWindow
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
67
react/features/pip/subscriber.ts
Normal file
67
react/features/pip/subscriber.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { isLocalTrackMuted } from '../base/tracks/functions.any';
|
||||
import { getElectronGlobalNS } from '../base/util/helpers';
|
||||
|
||||
import { requestPictureInPicture, shouldShowPiP, updateMediaSessionState } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Listens to audio and video mute state changes when PiP is active
|
||||
* and updates the MediaSession API to reflect the current state in PiP controls.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ (state: IReduxState) => {
|
||||
// Skip if PiP is disabled or shouldn't be shown (e.g., on prejoin without showOnPrejoin).
|
||||
if (!shouldShowPiP(state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPiPActive = state['features/pip']?.isPiPActive;
|
||||
|
||||
if (!isPiPActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
audioMuted: isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO),
|
||||
videoMuted: isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO)
|
||||
};
|
||||
},
|
||||
/* listener */ (muteState: { audioMuted: boolean; videoMuted: boolean; } | null) => {
|
||||
if (muteState === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateMediaSessionState({
|
||||
cameraActive: !muteState.videoMuted,
|
||||
microphoneActive: !muteState.audioMuted
|
||||
});
|
||||
},
|
||||
{
|
||||
deepEquals: true
|
||||
}
|
||||
);
|
||||
|
||||
if (browser.isElectron()) {
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ shouldShowPiP,
|
||||
/* listener */ (_shouldShowPiP: boolean) => {
|
||||
const electronNS = getElectronGlobalNS();
|
||||
|
||||
if (_shouldShowPiP) {
|
||||
// Expose requestPictureInPicture for Electron main process.
|
||||
if (!electronNS.requestPictureInPicture) {
|
||||
logger.debug('Exposing requestPictureInPicture to Electron namespace');
|
||||
electronNS.requestPictureInPicture = requestPictureInPicture;
|
||||
}
|
||||
} else if (typeof electronNS.requestPictureInPicture === 'function') {
|
||||
logger.debug('Removing requestPictureInPicture from Electron namespace (PiP disabled)');
|
||||
delete electronNS.requestPictureInPicture;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
151
react/features/pip/types.ts
Normal file
151
react/features/pip/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* MediaSession state for microphone and camera.
|
||||
*/
|
||||
export interface IMediaSessionState {
|
||||
/**
|
||||
* Whether the camera is active (unmuted).
|
||||
*/
|
||||
cameraActive: boolean;
|
||||
|
||||
/**
|
||||
* Whether the microphone is active (unmuted).
|
||||
*/
|
||||
microphoneActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Details supplied when MediaSession requests entering Document PiP.
|
||||
*/
|
||||
export interface IDocumentPiPMediaSessionActionDetails {
|
||||
/**
|
||||
* The MediaSession action being handled.
|
||||
*/
|
||||
action: 'enterpictureinpicture';
|
||||
|
||||
/**
|
||||
* The reason Document PiP was requested.
|
||||
*/
|
||||
enterPictureInPictureReason?: 'contentoccluded' | 'useraction';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a MediaSession request to enter Document PiP.
|
||||
*/
|
||||
export type DocumentPiPMediaSessionActionHandler = (
|
||||
details: IDocumentPiPMediaSessionActionDetails
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Options controlling user-facing failure handling when opening Document PiP.
|
||||
*/
|
||||
export interface IOpenDocumentPiPOptions {
|
||||
/**
|
||||
* Whether to show an error notification when opening the PiP window fails.
|
||||
*/
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's Document Picture-in-Picture API.
|
||||
*/
|
||||
export interface IDocumentPictureInPicture {
|
||||
/**
|
||||
* Registers a listener for Document PiP lifecycle events.
|
||||
*/
|
||||
addEventListener: (
|
||||
type: 'enter' | 'leave',
|
||||
listener: ((event: IDocumentPictureInPictureEvent) => void) | ((event: Event) => void)
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Opens a Document PiP window using the supplied window options.
|
||||
*/
|
||||
requestWindow: (options?: IDocumentPictureInPictureOptions) => Promise<Window>;
|
||||
|
||||
/**
|
||||
* The currently open Document PiP window, if any.
|
||||
*/
|
||||
readonly window: Window | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options used when requesting a Document PiP window.
|
||||
*/
|
||||
export interface IDocumentPictureInPictureOptions {
|
||||
/**
|
||||
* Whether the browser should hide its control for returning to the opener tab.
|
||||
*/
|
||||
disallowReturnToOpener?: boolean;
|
||||
|
||||
/**
|
||||
* The requested initial window height in CSS pixels.
|
||||
*/
|
||||
height?: number;
|
||||
|
||||
/**
|
||||
* Whether the browser should prefer the default placement over reusing the previous window position and size.
|
||||
*/
|
||||
preferInitialWindowPlacement?: boolean;
|
||||
|
||||
/**
|
||||
* The requested initial window width in CSS pixels.
|
||||
*/
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Document PiP lifecycle event containing the associated PiP window.
|
||||
*/
|
||||
export interface IDocumentPictureInPictureEvent extends Event {
|
||||
/**
|
||||
* The Document PiP window associated with the event.
|
||||
*/
|
||||
readonly window: Window;
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaSession actions supported by the PiP controls in addition to the standard action set.
|
||||
*/
|
||||
export type ExtendedMediaSessionAction = 'hangup' | 'togglecamera' | 'togglemicrophone';
|
||||
|
||||
/**
|
||||
* Details supplied to an extended MediaSession action handler.
|
||||
*/
|
||||
export interface IExtendedMediaSessionActionDetails {
|
||||
/**
|
||||
* The extended MediaSession action being handled.
|
||||
*/
|
||||
action: ExtendedMediaSessionAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an extended MediaSession action used by the PiP controls.
|
||||
*/
|
||||
export type ExtendedMediaSessionActionHandler = (
|
||||
details: IExtendedMediaSessionActionDetails
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* WebKit video presentation modes used to enter and leave Safari's native Video PiP.
|
||||
*/
|
||||
export type WebKitPresentationMode = 'inline' | 'picture-in-picture';
|
||||
|
||||
/**
|
||||
* Safari's non-standard Video PiP additions to HTMLVideoElement.
|
||||
*/
|
||||
export interface IWebKitPictureInPictureVideoElement extends HTMLVideoElement {
|
||||
/**
|
||||
* The video's current WebKit presentation mode.
|
||||
*/
|
||||
webkitPresentationMode?: WebKitPresentationMode;
|
||||
|
||||
/**
|
||||
* Changes the video's WebKit presentation mode.
|
||||
*/
|
||||
webkitSetPresentationMode?: (mode: WebKitPresentationMode) => void;
|
||||
|
||||
/**
|
||||
* Returns whether the video currently supports the requested WebKit presentation mode.
|
||||
*/
|
||||
webkitSupportsPresentationMode?: (mode: WebKitPresentationMode) => boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user