Files
jitsi-meet/react/features/pip/functions.ts
Hristo Terezov d06b847319 feat(pip): Add Picture-in-Picture support for Electron
Implements Picture-in-Picture functionality for the Electron wrapper to maintain video engagement when users are not actively focused on the conference window. This feature addresses the need to keep users visually connected to the conference even when multitasking.

Key features:
- Automatic PiP mode activation and deactivation based on user interaction
- Displays large video participant's stream or renders their avatar on canvas when video unavailable
- Provides audio/video mute controls via MediaSession API directly in PiP window
- Adds API events (_pip-requested) for Electron wrapper integration

Implementation includes new pip feature module with Redux architecture, canvas-based avatar rendering with custom backgrounds support, and integration with existing mute/unmute logic. Depends on jitsi-meet-electron-sdk#479 for proper user gesture handling in Electron.
2025-12-04 16:04:10 -06:00

462 lines
16 KiB
TypeScript

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