jeffsi-meet/react/features/pip/functions.ts

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