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

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
import React, { Component } from 'react';
import AlwaysOnTopView from './AlwaysOnTopView';
const { api } = window.alwaysOnTop;
/**
* The timeout in ms for hiding the toolbar.
*/
const TOOLBAR_TIMEOUT = 4000;
/**
* The type of the React {@code Component} state of {@link AlwaysOnTop}.
*/
interface IState {
avatarURL: string;
customAvatarBackgrounds: Array<string>;
displayName: string;
formattedDisplayName: string;
isVideoDisplayed: boolean;
userID: string;
visible: boolean;
}
/**
* Represents the always on top page.
*
* @class AlwaysOnTop
* @augments Component
*/
export default class AlwaysOnTop extends Component<any, IState> {
_hovered: boolean;
/**
* Initializes a new {@code AlwaysOnTop} instance.
*
* @param {*} props - The read-only properties with which the new instance
* is to be initialized.
*/
constructor(props: any) {
super(props);
this.state = {
avatarURL: '',
customAvatarBackgrounds: [],
displayName: '',
formattedDisplayName: '',
isVideoDisplayed: true,
userID: '',
visible: true
};
// Bind event handlers so they are only bound once per instance.
this._avatarChangedListener = this._avatarChangedListener.bind(this);
this._displayNameChangedListener
= this._displayNameChangedListener.bind(this);
this._videoChangedListener
= this._videoChangedListener.bind(this);
this._mouseMove = this._mouseMove.bind(this);
this._onMouseOut = this._onMouseOut.bind(this);
this._onMouseOver = this._onMouseOver.bind(this);
}
/**
* Handles avatar changed api events.
*
* @returns {void}
*/
_avatarChangedListener({ avatarURL, id }: { avatarURL: string; id: string; }) {
if (api._getOnStageParticipant() === id
&& avatarURL !== this.state.avatarURL) {
this.setState({ avatarURL });
}
}
/**
* Handles display name changed api events.
*
* @returns {void}
*/
_displayNameChangedListener({ displayname, formattedDisplayName, id }: { displayname: string;
formattedDisplayName: string; id: string; }) {
if (api._getOnStageParticipant() === id
&& (formattedDisplayName !== this.state.formattedDisplayName
|| displayname !== this.state.displayName)) {
// I think the API has a typo using lowercase n for the displayname
this.setState({
displayName: displayname,
formattedDisplayName
});
}
}
/**
* Hides the toolbar after a timeout.
*
* @returns {void}
*/
_hideToolbarAfterTimeout() {
setTimeout(
() => {
if (this._hovered) {
this._hideToolbarAfterTimeout();
} else {
this.setState({ visible: false });
}
},
TOOLBAR_TIMEOUT);
}
/**
* Handles large video changed api events.
*
* @returns {void}
*/
_videoChangedListener() {
const userID = api._getOnStageParticipant();
const avatarURL = api.getAvatarURL(userID);
const displayName = api.getDisplayName(userID);
const formattedDisplayName = api._getFormattedDisplayName(userID);
const isVideoDisplayed = Boolean(api._getPrejoinVideo?.() || api._getLargeVideo());
this.setState({
avatarURL,
displayName,
formattedDisplayName,
isVideoDisplayed,
userID
});
}
/**
* Handles mouse move events.
*
* @returns {void}
*/
_mouseMove() {
this.state.visible || this.setState({ visible: true });
}
/**
* Toolbar mouse out handler.
*
* @returns {void}
*/
_onMouseOut() {
this._hovered = false;
}
/**
* Toolbar mouse over handler.
*
* @returns {void}
*/
_onMouseOver() {
this._hovered = true;
}
/**
* Sets mouse move listener and initial toolbar timeout.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
api.on('avatarChanged', this._avatarChangedListener);
api.on('displayNameChange', this._displayNameChangedListener);
api.on('largeVideoChanged', this._videoChangedListener);
api.on('prejoinVideoChanged', this._videoChangedListener);
api.on('videoConferenceJoined', this._videoChangedListener);
this._videoChangedListener();
window.addEventListener('mousemove', this._mouseMove);
this._hideToolbarAfterTimeout();
api.getCustomAvatarBackgrounds()
.then((res: { avatarBackgrounds?: string[]; }) =>
this.setState({
customAvatarBackgrounds: res.avatarBackgrounds || []
}))
.catch(console.error);
}
/**
* Sets a timeout to hide the toolbar when the toolbar is shown.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate(_prevProps: any, prevState: IState) {
if (!prevState.visible && this.state.visible) {
this._hideToolbarAfterTimeout();
}
}
/**
* Removes all listeners.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
api.removeListener('avatarChanged', this._avatarChangedListener);
api.removeListener(
'displayNameChange',
this._displayNameChangedListener);
api.removeListener(
'largeVideoChanged',
this._videoChangedListener);
api.removeListener(
'prejoinVideoChanged',
this._videoChangedListener);
api.removeListener(
'videoConferenceJoined',
this._videoChangedListener);
window.removeEventListener('mousemove', this._mouseMove);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
avatarURL,
customAvatarBackgrounds,
displayName,
formattedDisplayName,
isVideoDisplayed,
visible
} = this.state;
return (
<AlwaysOnTopView
avatarURL = { avatarURL }
customAvatarBackgrounds = { customAvatarBackgrounds }
displayName = { displayName }
formattedDisplayName = { formattedDisplayName }
isVideoDisplayed = { isVideoDisplayed }
onMouseOut = { this._onMouseOut }
onMouseOver = { this._onMouseOver }
toolbarVisible = { visible } />
);
}
}

View File

@@ -0,0 +1,85 @@
import React, { ReactNode } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig).
import StatelessAvatar from '../base/avatar/components/web/StatelessAvatar';
import { getAvatarColor, getInitials } from '../base/avatar/functions';
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import Toolbar from './Toolbar';
/**
* Props shared by the legacy Always-on-Top and dedicated Document PiP renderers.
*/
interface IProps {
/** Participant avatar URL. */
avatarURL: string;
/** Optional renderer-owned media element. */
children?: ReactNode;
/** Configured avatar background palette. */
customAvatarBackgrounds: string[];
/** Participant display name used for initials and avatar hashing. */
displayName: string;
/** Display name formatted for presentation. */
formattedDisplayName: string;
/** Whether media is visible instead of the avatar fallback. */
isVideoDisplayed: boolean;
/** Pointer-leave handler used by toolbar visibility management. */
onMouseOut: (event?: React.MouseEvent) => void;
/** Pointer-enter handler used by toolbar visibility management. */
onMouseOver: (event?: React.MouseEvent) => void;
/** Whether the shared toolbar is visible. */
toolbarVisible: boolean;
}
/**
* Shared presentation used by the legacy and Document PiP always-on-top renderers.
*
* @returns {ReactElement}
*/
export default function AlwaysOnTopView({
avatarURL,
children,
customAvatarBackgrounds,
displayName,
formattedDisplayName,
isVideoDisplayed,
onMouseOut,
onMouseOver,
toolbarVisible
}: IProps) {
return (
<div id = 'alwaysOnTop'>
{ children }
<Toolbar
className = { toolbarVisible ? 'fadeIn' : 'fadeOut' }
onMouseOut = { onMouseOut }
onMouseOver = { onMouseOver } />
{ !isVideoDisplayed && (
<div id = 'videoNotAvailableScreen'>
<div id = 'avatarContainer'>
<StatelessAvatar
color = { getAvatarColor(displayName, customAvatarBackgrounds) }
iconUser = { DEFAULT_ICON.IconUser }
id = 'avatar'
initials = { getInitials(displayName) }
url = { avatarURL } />
</div>
<div
className = 'displayname'
id = 'displayname'>
{ formattedDisplayName }
</div>
</div>
) }
</div>
);
}

View File

@@ -0,0 +1,180 @@
import React, { Component } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import { IProps } from '../base/toolbox/components/AbstractButton';
import ToolbarButton from './ToolbarButton';
const { api } = window.alwaysOnTop;
/**
* The type of the React {@code Component} state of {@link AudioMuteButton}.
*/
interface IState {
/**
* Whether audio is available is not.
*/
audioAvailable: boolean;
/**
* Whether audio is muted or not.
*/
audioMuted: boolean;
}
type Props = Partial<IProps>;
/**
* Stateless "mute/unmute audio" button for the Always-on-Top windows.
*/
export default class AudioMuteButton extends Component<Props, IState> {
icon = DEFAULT_ICON.IconMic;
toggledIcon = DEFAULT_ICON.IconMicSlash;
accessibilityLabel = 'Audio mute';
/**
* Initializes a new {@code AudioMuteButton} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code AudioMuteButton} instance with.
*/
constructor(props: Props) {
super(props);
this.state = {
audioAvailable: false,
audioMuted: true
};
// Bind event handlers so they are only bound once per instance.
this._audioAvailabilityListener
= this._audioAvailabilityListener.bind(this);
this._audioMutedListener = this._audioMutedListener.bind(this);
this._onClick = this._onClick.bind(this);
}
/**
* Sets mouse move listener and initial toolbar timeout.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
api.on('audioAvailabilityChanged', this._audioAvailabilityListener);
api.on('audioMuteStatusChanged', this._audioMutedListener);
Promise.all([
api.isAudioAvailable(),
api.isAudioMuted(),
api.isAudioDisabled?.() || Promise.resolve(false)
])
.then(([ audioAvailable, audioMuted, audioDisabled ]) =>
this.setState({
audioAvailable: audioAvailable && !audioDisabled,
audioMuted
}))
.catch(console.error);
}
/**
* Removes all listeners.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
api.removeListener(
'audioAvailabilityChanged',
this._audioAvailabilityListener);
api.removeListener(
'audioMuteStatusChanged',
this._audioMutedListener);
}
/**
* Handles audio available api events.
*
* @param {{ available: boolean }} status - The new available status.
* @returns {void}
*/
_audioAvailabilityListener({ available }: { available: boolean; }) {
this.setState({ audioAvailable: available });
}
/**
* Handles audio muted api events.
*
* @param {{ muted: boolean }} status - The new muted status.
* @returns {void}
*/
_audioMutedListener({ muted }: { muted: boolean; }) {
this.setState({ audioMuted: muted });
}
/**
* Indicates if audio is currently muted or not.
*
* @override
* @protected
* @returns {boolean}
*/
_isAudioMuted() {
return this.state.audioMuted;
}
/**
* Indicates whether this button is disabled or not.
*
* @override
* @protected
* @returns {boolean}
*/
_isDisabled() {
return !this.state.audioAvailable;
}
/**
* Changes the muted state.
*
* @override
* @param {boolean} _audioMuted - Whether audio should be muted or not.
* @protected
* @returns {void}
*/
_setAudioMuted(_audioMuted: boolean) {
this.state.audioAvailable && api.executeCommand('toggleAudio');
}
/**
* Handles clicking / pressing the button, and toggles the audio mute state
* accordingly.
*
* @returns {void}
*/
_onClick() {
this._setAudioMuted(!this._isAudioMuted());
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const toggled = this._isAudioMuted();
return (
<ToolbarButton
accessibilityLabel = { this.accessibilityLabel }
disabled = { this._isDisabled() }
icon = { toggled ? this.toggledIcon : this.icon }
onClick = { this._onClick }
toggled = { toggled } />
);
}
}

View File

@@ -0,0 +1,329 @@
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>
);
}

View File

@@ -0,0 +1,60 @@
import React, { Component } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import { IProps } from '../base/toolbox/components/AbstractButton';
import ToolbarButton from './ToolbarButton';
const { api } = window.alwaysOnTop;
type Props = Partial<IProps>;
/**
* Stateless hangup button for the Always-on-Top windows.
*/
export default class HangupButton extends Component<Props> {
accessibilityLabel = 'Hangup';
icon = DEFAULT_ICON.IconHangup;
/**
* Initializes a new {@code HangupButton} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code HangupButton} instance with.
*/
constructor(props: Props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onClick = this._onClick.bind(this);
}
/**
* Handles clicking / pressing the button, and disconnects the conference.
*
* @protected
* @returns {void}
*/
_onClick() {
api.executeCommand('hangup');
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<ToolbarButton
accessibilityLabel = { this.accessibilityLabel }
customClass = 'hangup-button'
icon = { this.icon }
onClick = { this._onClick } />
);
}
}

View File

@@ -0,0 +1,137 @@
import React, { Component } from 'react';
import AudioMuteButton from './AudioMuteButton';
import HangupButton from './HangupButton';
import VideoMuteButton from './VideoMuteButton';
const { api } = window.alwaysOnTop;
/**
* The type of the React {@code Component} props of {@link Toolbar}.
*/
interface IProps {
/**
* Additional CSS class names to add to the root of the toolbar.
*/
className: string;
/**
* Callback invoked when no longer moused over the toolbar.
*/
onMouseOut: (e?: React.MouseEvent) => void;
/**
* Callback invoked when the mouse has moved over the toolbar.
*/
onMouseOver: (e?: React.MouseEvent) => void;
}
/**
* The type of the React {@code Component} state of {@link Toolbar}.
*/
interface IState {
/**
* Whether audio button to be shown or not.
*/
showAudioButton: boolean;
/**
* Whether video button to be shown or not.
*/
showVideoButton: boolean;
}
type Props = Partial<IProps>;
/**
* Represents the toolbar in the Always On Top window.
*
* @augments Component
*/
export default class Toolbar extends Component<Props, IState> {
/**
* Initializes a new {@code Toolbar} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize the new {@code Toolbar} instance with.
*/
constructor(props: Props) {
super(props);
this.state = {
showAudioButton: true,
showVideoButton: true
};
this._videoConferenceJoinedListener = this._videoConferenceJoinedListener.bind(this);
}
/**
* Sets listens for changing meetings while showing the toolbar.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
api.on('videoConferenceJoined', this._videoConferenceJoinedListener);
this._videoConferenceJoinedListener();
}
/**
* Handles is visitor changes.
*
* @returns {void}
*/
_videoConferenceJoinedListener() {
// for electron clients that embed the api and are not updated
if (!api.isVisitor) {
console.warn('external API not updated');
return;
}
const isNotVisitor = !api.isVisitor();
this.setState({
showAudioButton: isNotVisitor,
showVideoButton: isNotVisitor
});
}
/**
* Removes all listeners.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
api.removeListener('videoConferenceJoined', this._videoConferenceJoinedListener);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
className = '',
onMouseOut,
onMouseOver
} = this.props;
return (
<div
className = { `toolbox-content-items always-on-top-toolbox ${className}` }
onMouseOut = { onMouseOut }
onMouseOver = { onMouseOver }>
{ this.state.showAudioButton && <AudioMuteButton /> }
{ this.state.showVideoButton && <VideoMuteButton /> }
<HangupButton customClass = 'hangup-button' />
</div>
);
}
}

View File

@@ -0,0 +1,69 @@
import React, { useCallback } from 'react';
import Icon from '../base/icons/components/Icon';
interface IProps {
/**
* Accessibility label for button.
*/
accessibilityLabel: string;
/**
* An extra class name to be added at the end of the element's class name
* in order to enable custom styling.
*/
customClass?: string;
/**
* Whether or not the button is disabled.
*/
disabled?: boolean;
/**
* Button icon.
*/
icon: Function;
/**
* Click handler.
*/
onClick: (e?: React.MouseEvent) => void;
/**
* Whether or not the button is toggled.
*/
toggled?: boolean;
}
const ToolbarButton = ({
accessibilityLabel,
customClass,
disabled = false,
onClick,
icon,
toggled = false
}: IProps) => {
const onKeyPress = useCallback(event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
}, [ onClick ]);
return (<div
aria-disabled = { disabled }
aria-label = { accessibilityLabel }
aria-pressed = { toggled }
className = { `toolbox-button ${disabled ? ' disabled' : ''}` }
onClick = { disabled ? undefined : onClick }
onKeyPress = { disabled ? undefined : onKeyPress }
role = 'button'
tabIndex = { 0 }>
<div className = { `toolbox-icon ${disabled ? 'disabled' : ''} ${customClass ?? ''}` }>
<Icon src = { icon } />
</div>
</div>);
};
export default ToolbarButton;

View File

@@ -0,0 +1,180 @@
import React, { Component } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import { IProps } from '../base/toolbox/components/AbstractButton';
import ToolbarButton from './ToolbarButton';
const { api } = window.alwaysOnTop;
type Props = Partial<IProps>;
/**
* The type of the React {@code Component} state of {@link VideoMuteButton}.
*/
type State = {
/**
* Whether video is available is not.
*/
videoAvailable: boolean;
/**
* Whether video is muted or not.
*/
videoMuted: boolean;
};
/**
* Stateless "mute/unmute video" button for the Always-on-Top windows.
*/
export default class VideoMuteButton extends Component<Props, State> {
icon = DEFAULT_ICON.IconVideo;
toggledIcon = DEFAULT_ICON.IconVideoOff;
accessibilityLabel = 'Video mute';
/**
* Initializes a new {@code VideoMuteButton} instance.
*
* @param {Props} props - The React {@code Component} props to initialize
* the new {@code VideoMuteButton} instance with.
*/
constructor(props: Props) {
super(props);
this.state = {
videoAvailable: false,
videoMuted: true
};
// Bind event handlers so they are only bound once per instance.
this._videoAvailabilityListener
= this._videoAvailabilityListener.bind(this);
this._videoMutedListener = this._videoMutedListener.bind(this);
this._onClick = this._onClick.bind(this);
}
/**
* Sets mouse move listener and initial toolbar timeout.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
api.on('videoAvailabilityChanged', this._videoAvailabilityListener);
api.on('videoMuteStatusChanged', this._videoMutedListener);
Promise.all([
api.isVideoAvailable(),
api.isVideoMuted()
])
.then(([ videoAvailable, videoMuted ]) =>
this.setState({
videoAvailable,
videoMuted
}))
.catch(console.error);
}
/**
* Removes all listeners.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
api.removeListener(
'videoAvailabilityChanged',
this._videoAvailabilityListener);
api.removeListener(
'videoMuteStatusChanged',
this._videoMutedListener);
}
/**
* Indicates whether this button is disabled or not.
*
* @override
* @protected
* @returns {boolean}
*/
_isDisabled() {
return !this.state.videoAvailable;
}
/**
* Indicates if video is currently muted or not.
*
* @override
* @protected
* @returns {boolean}
*/
_isVideoMuted() {
return this.state.videoMuted;
}
/**
* Changes the muted state.
*
* @override
* @param {boolean} _videoMuted - Whether video should be muted or not.
* @protected
* @returns {void}
*/
_setVideoMuted(_videoMuted: boolean) {
this.state.videoAvailable && api.executeCommand('toggleVideo', false, true);
}
/**
* Handles video available api events.
*
* @param {{ available: boolean }} status - The new available status.
* @returns {void}
*/
_videoAvailabilityListener({ available }: { available: boolean; }) {
this.setState({ videoAvailable: available });
}
/**
* Handles video muted api events.
*
* @param {{ muted: boolean }} status - The new muted status.
* @returns {void}
*/
_videoMutedListener({ muted }: { muted: boolean; }) {
this.setState({ videoMuted: muted });
}
/**
* Handles clicking / pressing the button, and toggles the video mute state
* accordingly.
*
* @protected
* @returns {void}
*/
_onClick() {
this._setVideoMuted(!this._isVideoMuted());
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const toggled = this._isVideoMuted();
return (
<ToolbarButton
accessibilityLabel = { this.accessibilityLabel }
disabled = { this._isDisabled() }
icon = { toggled ? this.toggledIcon : this.icon }
onClick = { this._onClick }
toggled = { toggled } />
);
}
}

View File

@@ -0,0 +1,41 @@
/**
* Entry point for the Document PiP window renderer.
*
* Bootstraps the React tree into the PiP window document and unmounts it when
* the window is closed or reloaded.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import DocumentPiP from './DocumentPiP';
/**
* React root attached to the Document PiP window document.
*/
const root = createRoot(document.getElementById('react') as HTMLElement);
root.render(<DocumentPiP />);
/**
* Whether the renderer has already been cleaned up.
*/
let cleanedUp = false;
/**
* Unmounts the Document PiP renderer before the PiP window is closed or reloaded.
*
* @returns {void}
*/
const cleanup = () => {
if (cleanedUp) {
return;
}
cleanedUp = true;
window.removeEventListener('beforeunload', cleanup);
window.removeEventListener('pagehide', cleanup);
root.unmount();
};
window.addEventListener('beforeunload', cleanup);
window.addEventListener('pagehide', cleanup);

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import AlwaysOnTop from './AlwaysOnTop';
// Render the main/root Component.
const root = createRoot(document.getElementById('react') as HTMLElement);
root.render(<AlwaysOnTop />);
window.addEventListener('beforeunload', () => root.unmount());