diff --git a/custom.d.ts b/custom.d.ts
index 60bd434c6f..69f38596d4 100644
--- a/custom.d.ts
+++ b/custom.d.ts
@@ -2,3 +2,8 @@ declare module '*.svg' {
const content: any;
export default content;
}
+
+declare module '*.svg?raw' {
+ const content: string;
+ export default content;
+}
diff --git a/modules/API/API.js b/modules/API/API.js
index 4a8d892ebf..108b610467 100755
--- a/modules/API/API.js
+++ b/modules/API/API.js
@@ -107,6 +107,7 @@ import {
open as openParticipantsPane
} from '../../react/features/participants-pane/actions';
import { getParticipantsPaneOpen } from '../../react/features/participants-pane/functions';
+import { hidePiP, showPiP } from '../../react/features/pip/actions';
import { startLocalVideoRecording, stopLocalVideoRecording } from '../../react/features/recording/actions.any';
import { grantRecordingConsent, grantRecordingConsentAndUnmute } from '../../react/features/recording/actions.web';
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../react/features/recording/constants';
@@ -907,6 +908,12 @@ function initCommands() {
backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
virtualSource: backgroundImage
}, jitsiTrack));
+ },
+ 'show-pip': () => {
+ APP.store.dispatch(showPiP());
+ },
+ 'hide-pip': () => {
+ APP.store.dispatch(hidePiP());
}
};
transport.on('event', ({ data, name }) => {
@@ -2249,6 +2256,40 @@ class API {
});
}
+ /**
+ * Notify external application (if API is enabled) that Picture-in-Picture was requested.
+ * Used by Electron to handle PiP requests with proper user gesture context.
+ *
+ * @returns {void}
+ */
+ notifyPictureInPictureRequested() {
+ this._sendEvent({
+ name: '_pip-requested'
+ });
+ }
+
+ /**
+ * Notify external application (if API is enabled) that Picture-in-Picture mode was entered.
+ *
+ * @returns {void}
+ */
+ notifyPictureInPictureEntered() {
+ this._sendEvent({
+ name: 'pip-entered'
+ });
+ }
+
+ /**
+ * Notify external application (if API is enabled) that Picture-in-Picture mode was exited.
+ *
+ * @returns {void}
+ */
+ notifyPictureInPictureLeft() {
+ this._sendEvent({
+ name: 'pip-left'
+ });
+ }
+
/**
* Notify external application ( if API is enabled) that a participant menu button was clicked.
*
diff --git a/modules/API/external/external_api.js b/modules/API/external/external_api.js
index 9a821e2739..f0c6d66745 100644
--- a/modules/API/external/external_api.js
+++ b/modules/API/external/external_api.js
@@ -2,6 +2,7 @@ import { jitsiLocalStorage } from '@jitsi/js-utils/jitsi-local-storage';
import EventEmitter from 'events';
import { urlObjectToString } from '../../../react/features/base/util/uri';
+import { isPiPEnabled } from '../../../react/features/pip/external-api.shared';
import {
PostMessageTransportBackend,
Transport
@@ -94,7 +95,9 @@ const commands = {
toggleTileView: 'toggle-tile-view',
toggleVirtualBackgroundDialog: 'toggle-virtual-background',
toggleVideo: 'toggle-video',
- toggleWhiteboard: 'toggle-whiteboard'
+ toggleWhiteboard: 'toggle-whiteboard',
+ showPiP: 'show-pip',
+ hidePiP: 'hide-pip'
};
/**
@@ -102,6 +105,9 @@ const commands = {
* events expected by jitsi-meet.
*/
const events = {
+ '_pip-requested': '_pipRequested',
+ 'pip-entered': 'pipEntered',
+ 'pip-left': 'pipLeft',
'avatar-changed': 'avatarChanged',
'audio-availability-changed': 'audioAvailabilityChanged',
'audio-mute-status-changed': 'audioMuteStatusChanged',
@@ -330,6 +336,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this._myUserID = undefined;
this._onStageParticipant = undefined;
this._iAmvisitor = undefined;
+ this._pipConfig = configOverwrite?.pip;
this._setupListeners();
id++;
}
@@ -625,6 +632,26 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
// Since this is internal event we don't need to emit it to the consumer of the API.
return true;
+ case 'config-overwrite': {
+ // Update stored pip config when overwriteConfig is called.
+ if (data.pip !== undefined) {
+ const wasEnabled = isPiPEnabled(this._pipConfig);
+
+ this._pipConfig = data.pip;
+
+ const isEnabled = isPiPEnabled(this._pipConfig);
+
+ // Handle enable/disable transitions.
+ if (!wasEnabled && isEnabled) {
+ this._setupIntersectionObserver();
+ } else if (wasEnabled && !isEnabled) {
+ this._teardownIntersectionObserver();
+ }
+ }
+
+ // Since this is internal event we don't need to emit it to the consumer of the API.
+ return true;
+ }
}
const eventName = events[name];
@@ -649,6 +676,56 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this.emit(requestName, data, callback);
}
});
+
+ this._setupIntersectionObserver();
+ }
+
+ /**
+ * Sets up IntersectionObserver to monitor iframe visibility.
+ * Calls showPiP/hidePiP based on visibility.
+ *
+ * @private
+ * @returns {void}
+ */
+ _setupIntersectionObserver() {
+ if (!isPiPEnabled(this._pipConfig)) {
+ return;
+ }
+
+ // Don't create duplicate observers.
+ if (this._intersectionObserver) {
+ return;
+ }
+
+ this._isIntersecting = true;
+
+ this._intersectionObserver = new IntersectionObserver(entries => {
+ const entry = entries[entries.length - 1];
+ const wasIntersecting = this._isIntersecting;
+
+ this._isIntersecting = entry.isIntersecting;
+
+ if (!entry.isIntersecting && wasIntersecting) {
+ this.showPiP();
+ } else if (entry.isIntersecting && !wasIntersecting) {
+ this.hidePiP();
+ }
+ });
+
+ this._intersectionObserver.observe(this._frame);
+ }
+
+ /**
+ * Tears down IntersectionObserver.
+ *
+ * @private
+ * @returns {void}
+ */
+ _teardownIntersectionObserver() {
+ if (this._intersectionObserver) {
+ this._intersectionObserver.disconnect();
+ this._intersectionObserver = null;
+ }
}
/**
@@ -851,6 +928,8 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this.emit('_willDispose');
this._transport.dispose();
this.removeAllListeners();
+ this._teardownIntersectionObserver();
+
if (this._frame && this._frame.parentNode) {
this._frame.parentNode.removeChild(this._frame);
}
@@ -1496,6 +1575,24 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this.executeCommand('setVirtualBackground', enabled, backgroundImage);
}
+ /**
+ * Shows Picture-in-Picture window.
+ *
+ * @returns {void}
+ */
+ showPiP() {
+ this.executeCommand('showPiP');
+ }
+
+ /**
+ * Hides Picture-in-Picture window.
+ *
+ * @returns {void}
+ */
+ hidePiP() {
+ this.executeCommand('hidePiP');
+ }
+
/**
* Opens the desktop picker. This is invoked by the Electron SDK when gDM is used.
*
diff --git a/package-lock.json b/package-lock.json
index 4ebf24b527..7e52a9f9d6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18279,6 +18279,17 @@
"webrtc-adapter": "8.1.1"
}
},
+ "node_modules/lib-jitsi-meet/node_modules/@jitsi/js-utils": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/@jitsi/js-utils/-/js-utils-2.8.2.tgz",
+ "integrity": "sha512-tMFuci2lPmbQIFF/f3b5QdkL1vzY6sii9nj0e+K0EEJcFUJiX/QVkv5GbI6pMZ74BYAQXGFZqeASo8hKItniUA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@hapi/bourne": "3.0.0",
+ "js-md5": "0.7.3",
+ "ua-parser-js": "1.0.35"
+ }
+ },
"node_modules/lib-jitsi-meet/node_modules/@jitsi/logger": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@jitsi/logger/-/logger-2.0.2.tgz",
@@ -18301,6 +18312,12 @@
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT"
},
+ "node_modules/lib-jitsi-meet/node_modules/js-md5": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.7.3.tgz",
+ "integrity": "sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==",
+ "license": "MIT"
+ },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -39694,6 +39711,16 @@
"webrtc-adapter": "8.1.1"
},
"dependencies": {
+ "@jitsi/js-utils": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/@jitsi/js-utils/-/js-utils-2.8.2.tgz",
+ "integrity": "sha512-tMFuci2lPmbQIFF/f3b5QdkL1vzY6sii9nj0e+K0EEJcFUJiX/QVkv5GbI6pMZ74BYAQXGFZqeASo8hKItniUA==",
+ "requires": {
+ "@hapi/bourne": "3.0.0",
+ "js-md5": "0.7.3",
+ "ua-parser-js": "1.0.35"
+ }
+ },
"@jitsi/logger": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@jitsi/logger/-/logger-2.0.2.tgz",
@@ -39714,6 +39741,11 @@
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="
+ },
+ "js-md5": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.7.3.tgz",
+ "integrity": "sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ=="
}
}
},
diff --git a/react/features/app/components/App.web.tsx b/react/features/app/components/App.web.tsx
index b3fc07c1ef..ca38235ef0 100644
--- a/react/features/app/components/App.web.tsx
+++ b/react/features/app/components/App.web.tsx
@@ -5,6 +5,7 @@ import JitsiThemeProvider from '../../base/ui/components/JitsiThemeProvider.web'
import DialogContainer from '../../base/ui/components/web/DialogContainer';
import ChromeExtensionBanner from '../../chrome-extension-banner/components/ChromeExtensionBanner.web';
import OverlayContainer from '../../overlay/components/web/OverlayContainer';
+import PiP from '../../pip/components/PiP';
import { AbstractApp } from './AbstractApp';
@@ -47,6 +48,7 @@ export class App extends AbstractApp {
+
{ super._createMainElement(component, props) }
);
diff --git a/react/features/app/middlewares.web.ts b/react/features/app/middlewares.web.ts
index 11dc496100..9f46023269 100644
--- a/react/features/app/middlewares.web.ts
+++ b/react/features/app/middlewares.web.ts
@@ -12,6 +12,7 @@ import '../no-audio-signal/middleware';
import '../notifications/middleware';
import '../noise-detection/middleware';
import '../old-client-notification/middleware';
+import '../pip/middleware';
import '../power-monitor/middleware';
import '../prejoin/middleware';
import '../remote-control/middleware';
diff --git a/react/features/app/reducers.web.ts b/react/features/app/reducers.web.ts
index f81950b13d..238a196793 100644
--- a/react/features/app/reducers.web.ts
+++ b/react/features/app/reducers.web.ts
@@ -8,6 +8,7 @@ import '../keyboard-shortcuts/reducer';
import '../no-audio-signal/reducer';
import '../noise-detection/reducer';
import '../participants-pane/reducer';
+import '../pip/reducer';
import '../power-monitor/reducer';
import '../prejoin/reducer';
import '../remote-control/reducer';
diff --git a/react/features/app/types.ts b/react/features/app/types.ts
index 3b2a830144..0ef5d57350 100644
--- a/react/features/app/types.ts
+++ b/react/features/app/types.ts
@@ -57,6 +57,7 @@ import { INoiseDetectionState } from '../noise-detection/reducer';
import { INoiseSuppressionState } from '../noise-suppression/reducer';
import { INotificationsState } from '../notifications/reducer';
import { IParticipantsPaneState } from '../participants-pane/reducer';
+import { IPipState } from '../pip/reducer';
import { IPollsState } from '../polls/reducer';
import { IPollsHistoryState } from '../polls-history/reducer';
import { IPowerMonitorState } from '../power-monitor/reducer';
@@ -145,6 +146,7 @@ export interface IReduxState {
'features/noise-suppression': INoiseSuppressionState;
'features/notifications': INotificationsState;
'features/participants-pane': IParticipantsPaneState;
+ 'features/pip': IPipState;
'features/polls': IPollsState;
'features/polls-history': IPollsHistoryState;
'features/power-monitor': IPowerMonitorState;
diff --git a/react/features/base/avatar/components/web/StatelessAvatar.tsx b/react/features/base/avatar/components/web/StatelessAvatar.tsx
index 29eb4252e0..40690fb255 100644
--- a/react/features/base/avatar/components/web/StatelessAvatar.tsx
+++ b/react/features/base/avatar/components/web/StatelessAvatar.tsx
@@ -5,7 +5,18 @@ import Icon from '../../../icons/components/Icon';
import { pixelsToRem } from '../../../ui/functions.any';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
-import { PRESENCE_AVAILABLE_COLOR, PRESENCE_AWAY_COLOR, PRESENCE_BUSY_COLOR, PRESENCE_IDLE_COLOR } from '../styles';
+import {
+ PRESENCE_AVAILABLE_COLOR,
+ PRESENCE_AWAY_COLOR,
+ PRESENCE_BUSY_COLOR,
+ PRESENCE_IDLE_COLOR
+} from '../styles';
+
+import {
+ AVATAR_DEFAULT_BACKGROUND_COLOR,
+ getAvatarFont,
+ getAvatarInitialsColor
+} from './styles';
interface IProps extends IAvatarProps {
@@ -48,10 +59,10 @@ interface IProps extends IAvatarProps {
const useStyles = makeStyles()(theme => {
return {
avatar: {
- backgroundColor: '#AAA',
+ backgroundColor: AVATAR_DEFAULT_BACKGROUND_COLOR,
borderRadius: '50%',
- color: theme.palette?.text01 || '#fff',
- ...(theme.typography?.heading1 ?? {}),
+ color: getAvatarInitialsColor(theme),
+ ...getAvatarFont(theme),
fontSize: 'inherit',
objectFit: 'cover',
textAlign: 'center',
diff --git a/react/features/base/avatar/components/web/styles.ts b/react/features/base/avatar/components/web/styles.ts
new file mode 100644
index 0000000000..6da6d1c3ea
--- /dev/null
+++ b/react/features/base/avatar/components/web/styles.ts
@@ -0,0 +1,26 @@
+import { Theme } from '@mui/material/styles';
+
+// Default avatar background color
+export const AVATAR_DEFAULT_BACKGROUND_COLOR = '#AAA';
+
+/**
+ * Returns the avatar font style from the theme.
+ *
+ * @param {Theme} theme - The MUI theme.
+ * @returns {Object} The font style object containing fontFamily, fontWeight, etc.
+ */
+export const getAvatarFont = (theme: Theme) => theme.typography?.heading1 ?? {};
+
+/**
+ * Default text color for avatar initials.
+ */
+export const AVATAR_DEFAULT_INITIALS_COLOR = '#FFFFFF';
+
+/**
+ * Returns the text color for avatar initials from the theme.
+ *
+ * @param {Theme} theme - The MUI theme.
+ * @returns {string} The text color.
+ */
+export const getAvatarInitialsColor = (theme: Theme): string =>
+ theme.palette?.text01 || AVATAR_DEFAULT_INITIALS_COLOR;
diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts
index e8aa5ab579..27ed2aceb5 100644
--- a/react/features/base/config/configType.ts
+++ b/react/features/base/config/configType.ts
@@ -517,6 +517,10 @@ export interface IConfig {
peopleSearchQueryTypes?: string[];
peopleSearchTokenLocation?: string;
peopleSearchUrl?: string;
+ pip?: {
+ disabled?: boolean;
+ showOnPrejoin?: boolean;
+ };
preferBosh?: boolean;
preferVisitor?: boolean;
preferredTranscribeLanguage?: string;
diff --git a/react/features/base/config/configWhitelist.ts b/react/features/base/config/configWhitelist.ts
index e5e78ecc85..f86158f545 100644
--- a/react/features/base/config/configWhitelist.ts
+++ b/react/features/base/config/configWhitelist.ts
@@ -199,6 +199,7 @@ export default [
'participantMenuButtonsWithNotifyClick',
'participantsPane',
'pcStatsInterval',
+ 'pip',
'preferBosh',
'preferVisitor',
'prejoinConfig.enabled',
diff --git a/react/features/base/config/middleware.web.ts b/react/features/base/config/middleware.web.ts
index 0009f714fa..1b197505b2 100644
--- a/react/features/base/config/middleware.web.ts
+++ b/react/features/base/config/middleware.web.ts
@@ -13,11 +13,12 @@ import './middleware.any';
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
+ const result = next(action);
+
switch (action.type) {
case SET_CONFIG: {
const { initialWidth, stageFilmstripParticipants } = action.config.filmstrip || {};
const { dispatch, getState } = store;
- const result = next(action);
const state = getState();
if (stageFilmstripParticipants !== undefined) {
@@ -39,7 +40,9 @@ MiddlewareRegistry.register(store => next => action => {
window.config = state['features/base/config'];
}
- return result;
+ break;
}
}
+
+ return result;
});
diff --git a/react/features/base/util/helpers.ts b/react/features/base/util/helpers.ts
index 97a1113294..bb489a9026 100644
--- a/react/features/base/util/helpers.ts
+++ b/react/features/base/util/helpers.ts
@@ -22,7 +22,6 @@ export function assignIfDefined(target: Object, source: Object) {
return to;
}
-
const MATCH_OPERATOR_REGEXP = /[|\\{}()[\]^$+*?.-]/g;
/**
@@ -79,6 +78,21 @@ export function getJitsiMeetGlobalNS() {
return window.JitsiMeetJS.app;
}
+/**
+ * Gets the Electron-specific global namespace.
+ *
+ * @returns {Object} The Electron namespace.
+ */
+export function getElectronGlobalNS() {
+ const globalNS = getJitsiMeetGlobalNS();
+
+ if (!globalNS.electron) {
+ globalNS.electron = {};
+ }
+
+ return globalNS.electron;
+}
+
/**
* Returns the object that stores the connection times.
*
diff --git a/react/features/display-name/components/web/DisplayName.tsx b/react/features/display-name/components/web/DisplayName.tsx
index b89428ffac..8e63e0375a 100644
--- a/react/features/display-name/components/web/DisplayName.tsx
+++ b/react/features/display-name/components/web/DisplayName.tsx
@@ -13,6 +13,8 @@ import Tooltip from '../../../base/tooltip/components/Tooltip';
import { getIndicatorsTooltipPosition } from '../../../filmstrip/functions.web';
import { appendSuffix } from '../../functions';
+import { getDisplayNameColor } from './styles';
+
/**
* The type of the React {@code Component} props of {@link DisplayName}.
*/
@@ -49,7 +51,7 @@ const useStyles = makeStyles()(theme => {
return {
displayName: {
...theme.typography.labelBold,
- color: theme.palette.text01,
+ color: getDisplayNameColor(theme),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
@@ -62,7 +64,7 @@ const useStyles = makeStyles()(theme => {
boxShadow: 'none',
padding: 0,
...theme.typography.labelBold,
- color: theme.palette.text01
+ color: getDisplayNameColor(theme)
}
};
});
diff --git a/react/features/display-name/components/web/styles.ts b/react/features/display-name/components/web/styles.ts
index f00d25d63e..100b1210d0 100644
--- a/react/features/display-name/components/web/styles.ts
+++ b/react/features/display-name/components/web/styles.ts
@@ -121,3 +121,19 @@ export function scaleFontProperty(
return parseFloat(calculatedRemValue.toFixed(3));
}
+
+
+/**
+ * Default text color for display name.
+ */
+export const DISPLAY_NAME_DEFAULT_COLOR = '#FFFFFF';
+
+/**
+ * Returns the text color for display name from the theme.
+ *
+ * @param {Theme} theme - The MUI theme.
+ * @returns {string} The text color.
+ */
+export const getDisplayNameColor = (theme: Theme): string =>
+ theme.palette?.text01 || DISPLAY_NAME_DEFAULT_COLOR;
+
diff --git a/react/features/filmstrip/functions.web.ts b/react/features/filmstrip/functions.web.ts
index 665c4f88b9..61ae85b1b6 100644
--- a/react/features/filmstrip/functions.web.ts
+++ b/react/features/filmstrip/functions.web.ts
@@ -1,3 +1,5 @@
+import { Theme } from '@mui/material/styles';
+
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { isMobileBrowser } from '../base/environment/utils';
@@ -830,3 +832,13 @@ export function isTopPanelEnabled(state: IReduxState) {
return !filmstrip?.disableTopPanel && participantsCount >= (filmstrip?.minParticipantCountForTopPanel ?? 50);
}
+
+/**
+ * Returns the thumbnail background color from the theme.
+ *
+ * @param {Theme} theme - The MUI theme.
+ * @returns {string} The background color.
+ */
+export function getThumbnailBackgroundColor(theme: Theme): string {
+ return theme.palette.uiBackground;
+}
diff --git a/react/features/pip/actionTypes.ts b/react/features/pip/actionTypes.ts
new file mode 100644
index 0000000000..6c8f2c36a2
--- /dev/null
+++ b/react/features/pip/actionTypes.ts
@@ -0,0 +1,4 @@
+/**
+ * Action type to set Picture-in-Picture active state.
+ */
+export const SET_PIP_ACTIVE = 'SET_PIP_ACTIVE';
diff --git a/react/features/pip/actions.ts b/react/features/pip/actions.ts
new file mode 100644
index 0000000000..183dbbae02
--- /dev/null
+++ b/react/features/pip/actions.ts
@@ -0,0 +1,193 @@
+import { IStore } from '../app/types';
+import { MEDIA_TYPE } from '../base/media/constants';
+import { isLocalTrackMuted } from '../base/tracks/functions.any';
+import { handleToggleVideoMuted } from '../toolbox/actions.any';
+import { muteLocal } from '../video-menu/actions.any';
+
+import { SET_PIP_ACTIVE } from './actionTypes';
+import {
+ cleanupMediaSessionHandlers,
+ enterPiP,
+ setupMediaSessionHandlers,
+ shouldShowPiP
+} from './functions';
+import logger from './logger';
+
+/**
+ * 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
+ };
+}
+
+/**
+ * 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.
+ *
+ * @returns {Function}
+ */
+export function exitPiP() {
+ return (dispatch: IStore['dispatch']) => {
+ 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;
+
+ if (!isPiPActive) {
+ enterPiP(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;
+
+ 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;
+
+ if (!shouldShowPiP(state)) {
+ return;
+ }
+
+ if (!isPiPActive) {
+ const videoElement = document.getElementById('pipVideo') as HTMLVideoElement;
+
+ if (videoElement) {
+ enterPiP(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']) => {
+ const state = getState();
+ const isPiPActive = state['features/pip']?.isPiPActive;
+
+ if (isPiPActive) {
+ dispatch(exitPiP());
+ }
+ };
+}
diff --git a/react/features/pip/components/PiP.tsx b/react/features/pip/components/PiP.tsx
new file mode 100644
index 0000000000..c8863733f7
--- /dev/null
+++ b/react/features/pip/components/PiP.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+
+import { shouldShowPiP } from '../functions';
+
+import PiPVideoElement from './PiPVideoElement';
+
+/**
+ * Wrapper component that conditionally renders PiPVideoElement.
+ * Prevents mounting when PiP is disabled or on prejoin without showOnPrejoin flag.
+ *
+ * @returns {React.ReactElement | null}
+ */
+function PiP() {
+ const showPiP = useSelector(shouldShowPiP);
+
+ if (!showPiP) {
+ return null;
+ }
+
+ return ;
+}
+
+export default PiP;
diff --git a/react/features/pip/components/PiPVideoElement.tsx b/react/features/pip/components/PiPVideoElement.tsx
new file mode 100644
index 0000000000..fe18af6690
--- /dev/null
+++ b/react/features/pip/components/PiPVideoElement.tsx
@@ -0,0 +1,208 @@
+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 { getLocalParticipant, getParticipantDisplayName } from '../../base/participants/functions';
+import { isTrackStreamingStatusActive } from '../../connection-indicator/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 } from '../functions';
+import { useCanvasAvatar } from '../hooks';
+import logger from '../logger';
+
+const useStyles = makeStyles()(() => {
+ return {
+ hiddenVideo: {
+ position: 'absolute' as const,
+ width: '1px',
+ height: '1px',
+ opacity: 0,
+ pointerEvents: 'none' as const,
+ left: '-9999px',
+ top: '-9999px'
+ }
+ };
+});
+
+/**
+ * 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(null);
+ const previousTrackRef = useRef(null);
+
+ // 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 videoElement = videoRef.current;
+ const avatarFont = getAvatarFont(theme);
+ const fontFamily = (avatarFont as any).fontFamily ?? 'Inter, sans-serif';
+ const initialsColor = getAvatarInitialsColor(theme);
+ const displayNameColor = getDisplayNameColor(theme);
+ const { canvasStream } = useCanvasAvatar({
+ participant,
+ displayName,
+ customAvatarBackgrounds,
+ backgroundColor: getThumbnailBackgroundColor(theme),
+ fontFamily,
+ initialsColor,
+ displayNameColor
+ });
+
+ // Determine if we should show avatar instead of video.
+ const shouldShowAvatar = !videoTrack
+ || videoTrack.muted
+ || (!videoTrack.local && !isTrackStreamingStatusActive(videoTrack));
+
+ /**
+ * Effect: Handle switching between real video track and canvas avatar stream.
+ */
+ useEffect(() => {
+ if (!videoElement) {
+ return;
+ }
+
+ const previousTrack = previousTrackRef.current;
+
+ // 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.
+ // Only set srcObject if it's different to avoid interrupting playback.
+ if (canvasStream && videoElement.srcObject !== canvasStream) {
+ videoElement.srcObject = canvasStream;
+ }
+ } 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 (videoTrack?.jitsiTrack && videoElement) {
+ try {
+ videoTrack.jitsiTrack.detach(videoElement);
+ } catch (error) {
+ logger.error('Error during cleanup:', error);
+ }
+ }
+ };
+ }, [ videoTrack, shouldShowAvatar, canvasStream, videoElement ]);
+
+ /**
+ * Effect: Window blur/focus and visibility change listeners.
+ * Enters PiP on blur, exits on focus (matches old AOT behavior).
+ */
+ useEffect(() => {
+ if (!videoElement) {
+ 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 everythink seems to work well.
+ setTimeout(() => {
+ dispatch(handleWindowFocus());
+ }, 100);
+ };
+ const onVisibilityChange = () => {
+ if (document.hidden) {
+ onWindowBlur();
+ }
+ };
+
+ window.addEventListener('blur', onWindowBlur);
+ window.addEventListener('focus', onWindowFocus);
+ document.addEventListener('visibilitychange', onVisibilityChange);
+
+ return () => {
+ window.removeEventListener('blur', onWindowBlur);
+ window.removeEventListener('focus', onWindowFocus);
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ };
+ }, [ dispatch, videoElement ]);
+
+ /**
+ * Effect: PiP enter/leave event listeners.
+ * Updates Redux state when browser PiP events occur.
+ */
+ useEffect(() => {
+ if (!videoElement) {
+ 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);
+ };
+ }, [ videoElement, dispatch ]);
+
+ return (
+
+ );
+};
+
+export default PiPVideoElement;
diff --git a/react/features/pip/external-api.shared.ts b/react/features/pip/external-api.shared.ts
new file mode 100644
index 0000000000..feb6e5bfe2
--- /dev/null
+++ b/react/features/pip/external-api.shared.ts
@@ -0,0 +1,31 @@
+/**
+ * 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.
+ *
+ * @param {Object} pipConfig - The pip config object.
+ * @returns {boolean} - True if PiP is enabled.
+ */
+export function isPiPEnabled(pipConfig?: { disabled?: boolean; }): boolean {
+ if (pipConfig?.disabled) {
+ return false;
+ }
+
+ return isElectron();
+}
diff --git a/react/features/pip/functions.ts b/react/features/pip/functions.ts
new file mode 100644
index 0000000000..be376f2ce5
--- /dev/null
+++ b/react/features/pip/functions.ts
@@ -0,0 +1,461 @@
+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 { isPrejoinPageVisible } from '../prejoin/functions.any';
+
+import { toggleAudioFromPiP, toggleVideoFromPiP } from './actions';
+import { isPiPEnabled } from './external-api.shared';
+import logger from './logger';
+import { IMediaSessionState } from './types';
+
+/**
+ * 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);
+}
+
+/**
+ * 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;
+
+ // Check if PiP is enabled at all.
+ if (!isPiPEnabled(pipConfig)) {
+ 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}
+ */
+export async function drawImageAvatar(
+ ctx: CanvasRenderingContext2D,
+ imageUrl: string,
+ useCORS: boolean | undefined,
+ centerX: number,
+ centerY: number,
+ radius: number
+): Promise {
+ 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} 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,
+ 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} 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}
+ */
+export async function renderAvatarOnCanvas(
+ canvas: HTMLCanvasElement,
+ ctx: CanvasRenderingContext2D,
+ participant: IParticipant | undefined,
+ displayName: string,
+ customAvatarBackgrounds: Array,
+ defaultIcon: HTMLImageElement | null,
+ backgroundColor: string,
+ fontFamily: string,
+ initialsColor: string,
+ displayNameColor: string
+): Promise {
+ 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');
+
+ return;
+ }
+ if (document.pictureInPictureElement) {
+ logger.warn('Already in PiP mode');
+
+ 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...');
+
+ // Wait for metadata to load before requesting PiP.
+ video.addEventListener('loadedmetadata', () => {
+ // @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
+ video.requestPictureInPicture().catch((err: Error) => {
+ logger.error(`Error while requesting PiP after metadata loaded: ${err.message}`);
+ });
+ }, { once: true });
+
+ return;
+ }
+
+ // @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
+ video.requestPictureInPicture().catch((err: Error) => {
+ logger.error(`Error while requesting PiP: ${err.message}`);
+ });
+}
+
+/**
+ * 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 enterPiP(videoElement: HTMLVideoElement | undefined | null) {
+ if (!videoElement) {
+ logger.error('PiP video element not found');
+
+ 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');
+
+ APP.API.notifyPictureInPictureRequested();
+
+ // State will be updated by enterpictureinpicture event.
+ return;
+ }
+
+ // TODO: Enable PiP for browsers:
+ // In browsers, we should directly call requestPictureInPicture.
+ // @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
+ // requestPictureInPicture();
+ } catch (error) {
+ logger.error('Error entering Picture-in-Picture:', 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']) {
+ // @ts-ignore - MediaSession API is not fully typed in all environments.
+ if ('mediaSession' in navigator && navigator.mediaSession?.setActionHandler) {
+ try {
+ // Set up audio mute toggle handler.
+ // Dispatch action that will query current state and toggle.
+ // @ts-ignore - togglemicrophone is a newer MediaSession action.
+ navigator.mediaSession.setActionHandler('togglemicrophone', () => {
+ dispatch(toggleAudioFromPiP());
+ });
+
+ // Set up video mute toggle handler.
+ // Dispatch action that will query current state and toggle.
+ // @ts-ignore - togglecamera is a newer MediaSession action.
+ navigator.mediaSession.setActionHandler('togglecamera', () => {
+ dispatch(toggleVideoFromPiP());
+ });
+
+ // Set up hangup handler.
+ // @ts-ignore - hangup is a newer MediaSession action.
+ navigator.mediaSession.setActionHandler('hangup', () => {
+ dispatch(leaveConference());
+ });
+
+ logger.log('MediaSession API handlers registered for PiP controls');
+ } catch (error) {
+ logger.warn('Some MediaSession actions not supported:', error);
+ }
+ } 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) {
+ if ('mediaSession' in navigator) {
+ try {
+ // @ts-ignore - setMicrophoneActive is a newer MediaSession method.
+ if (navigator.mediaSession.setMicrophoneActive) {
+ // @ts-ignore
+ navigator.mediaSession.setMicrophoneActive(state.microphoneActive);
+ }
+
+ // @ts-ignore - setCameraActive is a newer MediaSession method.
+ if (navigator.mediaSession.setCameraActive) {
+ // @ts-ignore
+ navigator.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) {
+ try {
+ // Note: Setting handlers to null is commented out as it may cause issues
+ // in some browsers. The handlers will be overwritten when entering PiP again.
+ // @ts-ignore - togglemicrophone is a newer MediaSession action.
+ navigator.mediaSession.setActionHandler('togglemicrophone', null);
+ // @ts-ignore - togglecamera is a newer MediaSession action.
+ navigator.mediaSession.setActionHandler('togglecamera', null);
+ // @ts-ignore - hangup is a newer MediaSession action.
+ navigator.mediaSession.setActionHandler('hangup', null);
+ logger.log('MediaSession API handlers cleaned up');
+ } catch (error) {
+ logger.error('Error cleaning up MediaSession handlers:', error);
+ }
+ }
+}
+
+// Re-export from shared file for external use.
+export { isPiPEnabled };
+
diff --git a/react/features/pip/hooks.ts b/react/features/pip/hooks.ts
new file mode 100644
index 0000000000..fb2f101542
--- /dev/null
+++ b/react/features/pip/hooks.ts
@@ -0,0 +1,174 @@
+import { useEffect, useRef } from 'react';
+
+import IconUserSVG from '../base/icons/svg/user.svg?raw';
+import { IParticipant } from '../base/participants/types';
+import { TILE_ASPECT_RATIO } from '../filmstrip/constants';
+
+import { renderAvatarOnCanvas } from './functions';
+import 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;
+
+/**
+ * Options for the useCanvasAvatar hook.
+ */
+interface IUseCanvasAvatarOptions {
+ backgroundColor: string;
+ customAvatarBackgrounds: string[];
+ displayName: string;
+ displayNameColor: string;
+ fontFamily: string;
+ initialsColor: string;
+ participant: IParticipant | undefined;
+}
+
+/**
+ * Result returned by the useCanvasAvatar hook.
+ */
+interface IUseCanvasAvatarResult {
+ canvasStream: MediaStream | null;
+}
+
+/**
+ * Internal refs managed by the hook.
+ */
+interface ICanvasRefs {
+ canvas: HTMLCanvasElement | null;
+ defaultIcon: HTMLImageElement | null;
+ stream: MediaStream | 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('