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