Files
jitsi-meet/react/features/pip/actions.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

194 lines
5.3 KiB
TypeScript

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