mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-05-14 14:07:50 +00:00
Addresses multiple issues when enabling/disabling PiP dynamically: 1. External API: Replace dead config-overwrite event handler with interception in executeCommand. This properly manages the intersection observer and PiP state when pip config changes via overwriteConfig. 2. PiPVideoElement: Fix ref access pattern - access videoRef.current inside useEffects instead of capturing at render time. The captured value was null on first render, causing blur/focus listeners to never be set up when component mounted into a stable conference. 3. useCanvasAvatar: Return streamRef object instead of refs.current.stream so consumers can access .current inside their effects. The stream is created in an effect and wasn't available at render time. 4. Add on-mount focus check with loadedmetadata wait to handle PiP enable while app is in background, ensuring video source is ready before attempting to enter PiP mode.
234 lines
8.6 KiB
TypeScript
234 lines
8.6 KiB
TypeScript
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<HTMLVideoElement>(null);
|
|
const previousTrackRef = useRef<any>(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 avatarFont = getAvatarFont(theme);
|
|
const fontFamily = (avatarFont as any).fontFamily ?? 'Inter, sans-serif';
|
|
const initialsColor = getAvatarInitialsColor(theme);
|
|
const displayNameColor = getDisplayNameColor(theme);
|
|
const { canvasStreamRef } = 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(() => {
|
|
const videoElement = videoRef.current;
|
|
|
|
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.
|
|
// 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;
|
|
}
|
|
} 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 ]);
|
|
|
|
/**
|
|
* Effect: Window blur/focus and visibility change listeners.
|
|
* Enters PiP on blur, exits on focus (matches old AOT behavior).
|
|
*/
|
|
useEffect(() => {
|
|
const videoElement = videoRef.current;
|
|
|
|
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 everything 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);
|
|
|
|
// 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) {
|
|
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 = { classes.hiddenVideo }
|
|
id = 'pipVideo'
|
|
muted = { true }
|
|
playsInline = { true }
|
|
ref = { videoRef } />
|
|
);
|
|
};
|
|
|
|
export default PiPVideoElement;
|