init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
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 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';
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;
}
/**
* Renders display name and avatar for the on stage participant.
*
* @returns {ReactElement}
*/
_renderVideoNotAvailableScreen() {
const {
avatarURL,
customAvatarBackgrounds,
displayName,
formattedDisplayName,
isVideoDisplayed
} = this.state;
if (isVideoDisplayed) {
return null;
}
return (
<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>
);
}
/**
* 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() {
return (
<div id = 'alwaysOnTop'>
<Toolbar
className = { this.state.visible ? 'fadeIn' : 'fadeOut' }
onMouseOut = { this._onMouseOut }
onMouseOver = { this._onMouseOver } />
{ this._renderVideoNotAvailableScreen() }
</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,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,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import AlwaysOnTop from './AlwaysOnTop';
// Render the main/root Component.
/* eslint-disable-next-line react/no-deprecated */
ReactDOM.render(<AlwaysOnTop />, document.getElementById('react'));
window.addEventListener(
'beforeunload',
/* eslint-disable-next-line react/no-deprecated */
() => ReactDOM.unmountComponentAtNode(document.getElementById('react') ?? document.body));