snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View 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';

View 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
};
}

View 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;

View 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;

View 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
);
}

View 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>
);
}

View 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));

View 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;

View 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);
}

View 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
View 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;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('app:pip');

View 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;
});

View 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;
}
});

View 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
View 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;
}