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 { browser } from '../../base/lib-jitsi-meet'; import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants/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, shouldShowPiPAvatar } from '../functions'; import { useCanvasAvatar } from '../hooks'; import logger from '../logger'; import type { IWebKitPictureInPictureVideoElement } from '../types'; /** * Electron-only delay before reacting to window focus, so that the browser's * leavepictureinpicture event is processed first (see the comment at the usage site). */ const FOCUS_CHECK_DELAY_MS = 100; /** * Interval between canvas frame re-publish attempts while WebKit asynchronously wires the video * element to the canvas stream (see the comment at the usage site for why polling is needed). */ const WEBKIT_REPUBLISH_INTERVAL_MS = 50; /** * Cap on canvas frame re-publish attempts (a ~1.25 s total budget). One attempt has been enough * in practice (Safari 26.6); the bound only prevents polling forever against a dead stream. */ const WEBKIT_REPUBLISH_MAX_ATTEMPTS = 25; const baseVideoStyle = { width: '1px', height: '1px', pointerEvents: 'none' as const, opacity: 0, position: 'absolute' as const }; const useStyles = makeStyles()(() => { return { hiddenVideo: { ...baseVideoStyle, left: '-9999px', top: '-9999px' }, // Safari 26.5.2 was observed to produce black PiP content when this video was positioned offscreen. webKitVideo: { ...baseVideoStyle, left: 0, top: 0 } }; }); /** * 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); // True only when PiP was dismissed while the tab was still hidden; reset once the tab becomes // visible again. Safari 26.5.2 fires `playing` after such a dismissal, which would immediately // reopen PiP, so automatic re-entry is suppressed while this is set. const webKitPiPDismissedWhileHiddenRef = useRef(false); // 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 shouldShowAvatar = shouldShowPiPAvatar(videoTrack); const { canvasStreamRef, publishFrame } = useCanvasAvatar({ participant, displayName, customAvatarBackgrounds, backgroundColor: getThumbnailBackgroundColor(theme), fontFamily, initialsColor, displayNameColor, shouldShowAvatar }); /** * Effect: Handle switching between real video track and canvas avatar stream. */ useEffect(() => { const videoElement = videoRef.current; if (!videoElement) { return; } const previousTrack = previousTrackRef.current; let republishInterval: number | undefined; // 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; publishFrame(); // Safari wires the element to a new srcObject asynchronously and silently drops canvas // frames published before that completes: streams never buffer for late sinks (W3C // resolution in w3c/mediacapture-transform#114) and the media-provider fetch runs // "in parallel" per the HTML spec, so no readiness event exists — republishing until // the element itself reports data is the only reliable strategy. Chromium captures // synchronously on requestFrame() and redelivers the current frame to late sinks, // so it needs no retry. if (browser.isWebKitBased()) { let attempts = 0; republishInterval = window.setInterval(() => { const done = videoElement.readyState >= videoElement.HAVE_CURRENT_DATA || videoElement.srcObject !== canvasStream; if (done || ++attempts > WEBKIT_REPUBLISH_MAX_ATTEMPTS) { window.clearInterval(republishInterval); republishInterval = undefined; if (!done) { logger.warn('Canvas stream produced no data after repeated frame requests'); } return; } publishFrame(); }, WEBKIT_REPUBLISH_INTERVAL_MS); } } } 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 (republishInterval) { window.clearInterval(republishInterval); } if (videoTrack?.jitsiTrack && videoElement) { try { videoTrack.jitsiTrack.detach(videoElement); } catch (error) { logger.error('Error during cleanup:', error); } } }; }, [ videoTrack, shouldShowAvatar, publishFrame ]); /** * Effect: Use WebKit presentation modes to enter and leave Video PiP on tab switches. * */ useEffect(() => { const videoElement = videoRef.current as IWebKitPictureInPictureVideoElement | null; if (!videoElement || !browser.isWebKitBased() || typeof videoElement.webkitSupportsPresentationMode !== 'function' || typeof videoElement.webkitSetPresentationMode !== 'function') { return; } let enteringWebKitPiP = false; let isPiPActive = false; const resumeWebKitPiPPlayback = () => { if (videoElement.webkitPresentationMode !== 'picture-in-picture' || !videoElement.paused) { return; } // A muted live-MediaStream mirror has no meaningful paused state: any pause while in PiP // (Safari's hidden-tab interruption churn around the PiP transition, media keys) just // freezes the PiP frame, so playback is always resumed. videoElement.play() .catch(error => logger.warn('Failed to resume WebKit Picture-in-Picture video:', error)); }; const onEnterPiP = () => { if (!isPiPActive) { isPiPActive = true; dispatch(handlePipEnterEvent()); } }; const onLeavePiP = () => { if (!isPiPActive) { return; } isPiPActive = false; dispatch(handlePiPLeaveEvent()); webKitPiPDismissedWhileHiddenRef.current = document.hidden; }; const enterWebKitPiP = async () => { if (enteringWebKitPiP || videoElement.webkitPresentationMode === 'picture-in-picture') { return; } enteringWebKitPiP = true; try { if (videoElement.paused) { // Safari pauses hidden MediaStream videos, but a rejected play() (autoplay policy) // must not prevent the PiP entry attempt — WebKit does the final eligibility check // and the playback recovery below resumes playback once PiP is entered. await videoElement.play() .catch(error => logger.warn('Failed to play video before entering WebKit PiP:', error)); } // The tab may have become visible or PiP may have been dismissed while play() was pending. if (!document.hidden || webKitPiPDismissedWhileHiddenRef.current) { return; } const hasCurrentMedia = !videoElement.ended && videoElement.readyState >= videoElement.HAVE_CURRENT_DATA; if (hasCurrentMedia && videoElement.webkitSupportsPresentationMode?.('picture-in-picture')) { videoElement.webkitSetPresentationMode?.('picture-in-picture'); } } catch (error) { logger.warn('Failed to enter WebKit Picture-in-Picture:', error); } finally { enteringWebKitPiP = false; } }; const exitWebKitPiP = () => { if (videoElement.webkitPresentationMode !== 'picture-in-picture') { return; } try { videoElement.webkitSetPresentationMode?.('inline'); } catch (error) { logger.warn('Failed to exit WebKit Picture-in-Picture:', error); } }; const onVisibilityChange = (event: Event) => { if (!event.isTrusted) { return; } if (document.hidden) { if (!webKitPiPDismissedWhileHiddenRef.current) { void enterWebKitPiP(); } } else { webKitPiPDismissedWhileHiddenRef.current = false; exitWebKitPiP(); } }; const onPlaying = (event: Event) => { if (event.isTrusted && document.hidden && !webKitPiPDismissedWhileHiddenRef.current) { void enterWebKitPiP(); } }; const onWebKitPresentationModeChanged = () => { if (videoElement.webkitPresentationMode === 'picture-in-picture') { resumeWebKitPiPPlayback(); onEnterPiP(); } else { onLeavePiP(); } }; document.addEventListener('visibilitychange', onVisibilityChange); videoElement.addEventListener('pause', resumeWebKitPiPPlayback); videoElement.addEventListener('playing', onPlaying); videoElement.addEventListener('webkitpresentationmodechanged', onWebKitPresentationModeChanged); return () => { document.removeEventListener('visibilitychange', onVisibilityChange); videoElement.removeEventListener('pause', resumeWebKitPiPPlayback); videoElement.removeEventListener('playing', onPlaying); videoElement.removeEventListener('webkitpresentationmodechanged', onWebKitPresentationModeChanged); exitWebKitPiP(); }; }, [ dispatch ]); /** * Effect: Electron-only window blur/focus and visibility change listeners. * Enters PiP on blur and exits on focus, matching the old AOT behavior. */ useEffect(() => { const videoElement = videoRef.current; if (!videoElement || !browser.isElectron()) { 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()); }, FOCUS_CHECK_DELAY_MS); }; 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 || browser.isWebKitBased()) { 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 (