Files
upstream-mirror 1d2bfcac0c
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s
snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
2026-09-15 15:56:32 +05:00

330 lines
10 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import MediaCastReceiver from '../base/media-cast/MediaCastReceiver';
import type { MediaCastSignal, MediaCastSignalHandler } from '../base/media-cast/types.web';
import AlwaysOnTopView from './AlwaysOnTopView';
/**
* External API instance exposed to the dedicated Document PiP window.
*/
const { api } = window.alwaysOnTop;
/**
* Delay in milliseconds before hiding the PiP toolbar after pointer activity stops.
*/
const TOOLBAR_TIMEOUT = 4000;
/**
* Layout applied to the React-owned video element without cropping shared content.
*/
const VIDEO_STYLE: React.CSSProperties = {
height: '100%',
left: 0,
objectFit: 'contain',
position: 'absolute',
top: 0,
width: '100%'
};
/**
* Participant metadata rendered by the shared always-on-top presentation.
*/
interface IParticipantState {
/** Participant avatar URL. */
avatarURL: string;
/** Configured avatar background palette. */
customAvatarBackgrounds: string[];
/** Participant display name used for avatar hashing. */
displayName: string;
/** Participant display name formatted for presentation. */
formattedDisplayName: string;
}
/**
* Signals received from the meeting iframe before the media-cast receiver is ready are buffered
* here and flushed once the receiver registers itself. The host forwards signals through the
* embedder API, which is available from the moment this bundle executes, so buffering must start
* at module scope rather than on React mount.
*/
const pendingSignals: MediaCastSignal[] = [];
/**
* The signal handler the media-cast receiver has registered, if any.
*/
let receiverSignalHandler: MediaCastSignalHandler | undefined;
/**
* Whether the API signal listener is currently registered.
*/
let signalBridgeRegistered = false;
/**
* Forwards one media-cast signal to the receiver, buffering it until the receiver is ready.
*
* @param {Object} payload - Event payload received from the embedder API.
* @param {MediaCastSignal} payload.signal - The signal to forward.
* @returns {void}
*/
const onSignal = ({ signal }: { signal: MediaCastSignal; }) => {
if (receiverSignalHandler) {
receiverSignalHandler(signal);
} else {
pendingSignals.push(signal);
}
};
/**
* Registers the media-cast receiver's signal handler, flushing any buffered signals, and returns
* a function that unregisters it.
*
* @param {MediaCastSignalHandler} handler - The receiver's signal handler.
* @returns {Function} Unregister function.
*/
const registerSignalHandler = (handler: MediaCastSignalHandler): (() => void) => {
receiverSignalHandler = handler;
for (const signal of pendingSignals.splice(0)) {
handler(signal);
}
return () => {
if (receiverSignalHandler === handler) {
receiverSignalHandler = undefined;
}
};
};
/**
* Registers the API listener at most once. It is invoked at module load so signals arriving before
* React mounts are buffered, and again on mount to tolerate development-only remounts.
*
* @returns {void}
*/
const registerSignalBridge = () => {
if (signalBridgeRegistered) {
return;
}
signalBridgeRegistered = true;
api.on('_documentPiPSignal', onSignal);
};
/**
* Removes the API listener and clears all receiver and buffered-signal references.
*
* @returns {void}
*/
const cleanupSignalBridge = () => {
if (signalBridgeRegistered) {
api.removeListener('_documentPiPSignal', onSignal);
signalBridgeRegistered = false;
}
receiverSignalHandler = undefined;
pendingSignals.length = 0;
};
registerSignalBridge();
/**
* Document PiP renderer backed by a media-cast receiver.
*
* @returns {ReactElement}
*/
export default function DocumentPiP() {
const [ participant, setParticipant ] = useState<IParticipantState>({
avatarURL: '',
customAvatarBackgrounds: [],
displayName: '',
formattedDisplayName: ''
});
const [ sourceVisible, setSourceVisible ] = useState(
() => Boolean(api._isLargeVideoVisible() || api._isPrejoinVideoVisible()));
const [ toolbarVisible, setToolbarVisible ] = useState(true);
const [ track, setTrack ] = useState<MediaStreamTrack | null>(null);
const [ trackMuted, setTrackMuted ] = useState(true);
const hoveredRef = useRef(false);
const toolbarTimerRef = useRef<number | undefined>(undefined);
const videoRef = useRef<HTMLVideoElement>(null);
const updateParticipant = useCallback(() => {
const userID = api._getOnStageParticipant();
setParticipant(current => ({
...current,
avatarURL: api.getAvatarURL(userID),
displayName: api.getDisplayName(userID),
formattedDisplayName: api._getFormattedDisplayName(userID)
}));
setSourceVisible(Boolean(api._isLargeVideoVisible() || api._isPrejoinVideoVisible()));
}, []);
const scheduleToolbarHide = useCallback(() => {
const hideWhenIdle = () => {
if (hoveredRef.current) {
toolbarTimerRef.current = window.setTimeout(hideWhenIdle, TOOLBAR_TIMEOUT);
} else {
setToolbarVisible(false);
}
};
window.clearTimeout(toolbarTimerRef.current);
toolbarTimerRef.current = window.setTimeout(hideWhenIdle, TOOLBAR_TIMEOUT);
}, []);
const onMouseOut = useCallback(() => {
hoveredRef.current = false;
}, []);
const onMouseOver = useCallback(() => {
hoveredRef.current = true;
}, []);
useEffect(() => {
let active = true;
registerSignalBridge();
const receiver = new MediaCastReceiver({
onError: error => console.error('Document PiP receiver failed', error),
onSignal: signal => api._sendDocumentPiPSignal(signal),
onTrack: nextTrack => {
if (active) {
setTrack(nextTrack);
}
}
});
const unregister = registerSignalHandler(signal => receiver.handleSignal(signal));
return () => {
active = false;
unregister();
receiver.dispose();
cleanupSignalBridge();
};
}, []);
useEffect(() => {
if (!track) {
setTrackMuted(true);
return;
}
const updateMuted = () => setTrackMuted(track.muted || track.readyState !== 'live');
track.addEventListener('mute', updateMuted);
track.addEventListener('unmute', updateMuted);
track.addEventListener('ended', updateMuted);
updateMuted();
return () => {
track.removeEventListener('mute', updateMuted);
track.removeEventListener('unmute', updateMuted);
track.removeEventListener('ended', updateMuted);
};
}, [ track ]);
useEffect(() => {
const video = videoRef.current;
if (!video) {
return;
}
video.srcObject = track ? new MediaStream([ track ]) : null;
return () => {
video.srcObject = null;
};
}, [ track ]);
useEffect(() => {
const avatarChanged = ({ avatarURL, id }: { avatarURL: string; id: string; }) => {
if (api._getOnStageParticipant() === id) {
setParticipant(current => ({
...current,
avatarURL
}));
}
};
const displayNameChanged = ({ displayname, formattedDisplayName, id }: {
displayname: string;
formattedDisplayName: string;
id: string;
}) => {
if (api._getOnStageParticipant() === id) {
setParticipant(current => ({
...current,
displayName: displayname,
formattedDisplayName
}));
}
};
api.on('avatarChanged', avatarChanged);
api.on('displayNameChange', displayNameChanged);
api.on('largeVideoChanged', updateParticipant);
api.on('prejoinVideoChanged', updateParticipant);
api.on('videoConferenceJoined', updateParticipant);
updateParticipant();
api.getCustomAvatarBackgrounds()
.then(({ avatarBackgrounds = [] }: { avatarBackgrounds?: string[]; }) => {
setParticipant(current => ({
...current,
customAvatarBackgrounds: avatarBackgrounds
}));
})
.catch(console.error);
return () => {
api.removeListener('avatarChanged', avatarChanged);
api.removeListener('displayNameChange', displayNameChanged);
api.removeListener('largeVideoChanged', updateParticipant);
api.removeListener('prejoinVideoChanged', updateParticipant);
api.removeListener('videoConferenceJoined', updateParticipant);
};
}, [ updateParticipant ]);
useEffect(() => {
const onMouseMove = () => {
setToolbarVisible(true);
scheduleToolbarHide();
};
window.addEventListener('mousemove', onMouseMove);
scheduleToolbarHide();
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.clearTimeout(toolbarTimerRef.current);
};
}, [ scheduleToolbarHide ]);
const isVideoDisplayed = Boolean(track && !trackMuted && sourceVisible);
return (
<AlwaysOnTopView
avatarURL = { participant.avatarURL }
customAvatarBackgrounds = { participant.customAvatarBackgrounds }
displayName = { participant.displayName }
formattedDisplayName = { participant.formattedDisplayName }
isVideoDisplayed = { isVideoDisplayed }
onMouseOut = { onMouseOut }
onMouseOver = { onMouseOver }
toolbarVisible = { toolbarVisible }>
<video
autoPlay = { true }
hidden = { !isVideoDisplayed }
muted = { true }
playsInline = { true }
ref = { videoRef }
style = { VIDEO_STYLE } />
</AlwaysOnTopView>
);
}