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,43 @@
/**
* Action to select the participant to be displayed in LargeVideo.
*
* {
* type: SELECT_LARGE_VIDEO_PARTICIPANT,
* participantId: (string|undefined)
* }
*/
export const SELECT_LARGE_VIDEO_PARTICIPANT
= 'SELECT_LARGE_VIDEO_PARTICIPANT';
/**
* Action to set the dimensions of the large video.
*
* {
* type: SET_LARGE_VIDEO_DIMENSIONS,
* height: number,
* width: number
* }
*/
export const SET_LARGE_VIDEO_DIMENSIONS = 'SET_LARGE_VIDEO_DIMENSIONS';
/**
* Action to update the redux store with the current resolution of large video.
*
* @returns {{
* type: UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION,
* resolution: number
* }}
*/
export const UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION
= 'UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION';
/**
* Action to set the redux store of the current show me what I'm sharing flag value.
*
* @returns {{
* type: SET_SEE_WHAT_IS_BEING_SHARED,
* seeWhatIsBeingShared: boolean
* }}
*/
export const SET_SEE_WHAT_IS_BEING_SHARED
= 'SET_SEE_WHAT_IS_BEING_SHARED';

View File

@@ -0,0 +1,201 @@
import { IReduxState, IStore } from '../app/types';
import { IStateful } from '../base/app/types';
import { MEDIA_TYPE } from '../base/media/constants';
import {
getDominantSpeakerParticipant,
getLocalParticipant,
getLocalScreenShareParticipant,
getParticipantById,
getPinnedParticipant,
getRemoteParticipants,
getVirtualScreenshareParticipantByOwnerId
} from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import { isStageFilmstripAvailable } from '../filmstrip/functions';
import { getAutoPinSetting } from '../video-layout/functions';
import {
SELECT_LARGE_VIDEO_PARTICIPANT,
SET_LARGE_VIDEO_DIMENSIONS,
UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION
} from './actionTypes';
/**
* Action to select the participant to be displayed in LargeVideo based on the
* participant id provided. If a participant id is not provided, the LargeVideo
* participant will be selected based on a variety of factors: If there is a
* dominant or pinned speaker, or if there are remote tracks, etc.
*
* @param {string} participant - The participant id of the user that needs to be
* displayed on the large video.
* @returns {Function}
*/
export function selectParticipantInLargeVideo(participant?: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
if (isStageFilmstripAvailable(state, 2)) {
return;
}
// Keep Etherpad open.
if (state['features/etherpad'].editing) {
return;
}
const participantId = participant ?? _electParticipantInLargeVideo(state);
const largeVideo = state['features/large-video'];
const remoteScreenShares = state['features/video-layout'].remoteScreenShares;
let latestScreenshareParticipantId;
if (remoteScreenShares?.length) {
latestScreenshareParticipantId = remoteScreenShares[remoteScreenShares.length - 1];
}
// When trying to auto pin screenshare, always select the endpoint even though it happens to be
// the large video participant in redux (for the reasons listed above in the large video selection
// logic above). The auto pin screenshare logic kicks in after the track is added
// (which updates the large video participant and selects all endpoints because of the auto tile
// view mode). If the screenshare endpoint is not among the forwarded endpoints from the bridge,
// it needs to be selected again at this point.
if (participantId !== largeVideo.participantId || participantId === latestScreenshareParticipantId) {
dispatch({
type: SELECT_LARGE_VIDEO_PARTICIPANT,
participantId
});
}
};
}
/**
* Updates the currently seen resolution of the video displayed on large video.
*
* @param {number} resolution - The current resolution (height) of the video.
* @returns {{
* type: UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION,
* resolution: number
* }}
*/
export function updateKnownLargeVideoResolution(resolution: number) {
return {
type: UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION,
resolution
};
}
/**
* Sets the dimensions of the large video in redux.
*
* @param {number} height - The height of the large video.
* @param {number} width - The width of the large video.
* @returns {{
* type: SET_LARGE_VIDEO_DIMENSIONS,
* height: number,
* width: number
* }}
*/
export function setLargeVideoDimensions(height: number, width: number) {
return {
type: SET_LARGE_VIDEO_DIMENSIONS,
height,
width
};
}
/**
* Returns the most recent existing remote video track.
*
* @param {Function|Object} stateful - The redux store or {@code getState} function.
* @private
* @returns {(Track|undefined)}
*/
function _electLastVisibleRemoteParticipant(stateful: IStateful) {
const state = toState(stateful);
const tracks = state['features/base/tracks'];
// First we try to get most recent remote video track.
for (let i = tracks.length - 1; i >= 0; --i) {
const track = tracks[i];
if (!track.local && track.mediaType === MEDIA_TYPE.VIDEO && track.participantId) {
const participant = getParticipantById(state, track.participantId);
if (participant) {
return participant;
}
}
}
}
/**
* Returns the identifier of the participant who is to be on the stage and
* should be displayed in {@code LargeVideo}.
*
* @param {Object} state - The Redux state from which the participant to be
* displayed in {@code LargeVideo} is to be elected.
* @private
* @returns {(string|undefined)}
*/
function _electParticipantInLargeVideo(state: IReduxState) {
// If a participant is pinned, they will be shown in the LargeVideo (regardless of whether they are local or
// remote) when the filmstrip on stage is disabled.
let participant = getPinnedParticipant(state);
if (participant) {
return participant.id;
}
const autoPinSetting = getAutoPinSetting();
if (autoPinSetting) {
// when the setting auto_pin_latest_screen_share is true as spot does, prioritize local screenshare
if (autoPinSetting === true) {
const localScreenShareParticipant = getLocalScreenShareParticipant(state);
if (localScreenShareParticipant) {
return localScreenShareParticipant.id;
}
}
// Pick the most recent remote screenshare that was added to the conference.
const remoteScreenShares = state['features/video-layout'].remoteScreenShares;
if (remoteScreenShares?.length) {
return remoteScreenShares[remoteScreenShares.length - 1];
}
}
// Next, pick the dominant speaker (other than self).
participant = getDominantSpeakerParticipant(state);
if (participant && !participant.local) {
// Return the screensharing participant id associated with this endpoint if multi-stream is enabled and
// auto_pin_latest_screen_share setting is disabled.
const screenshareParticipant = getVirtualScreenshareParticipantByOwnerId(state, participant.id);
return screenshareParticipant?.id ?? participant.id;
}
// In case this is the local participant.
participant = undefined;
// Next, pick the most recent participant with video.
const lastVisibleRemoteParticipant = _electLastVisibleRemoteParticipant(state);
if (lastVisibleRemoteParticipant) {
return lastVisibleRemoteParticipant.id;
}
// Last, select the participant that joined last (other than poltergist or other bot type participants).
const participants = [ ...getRemoteParticipants(state).values() ];
for (let i = participants.length; i > 0 && !participant; i--) {
const p = participants[i - 1];
!p.botType && (participant = p);
}
if (participant) {
return participant.id;
}
return getLocalParticipant(state)?.id;
}

View File

@@ -0,0 +1 @@
export * from './actions.any';

View File

@@ -0,0 +1,102 @@
// @ts-expect-error
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { IStore } from '../app/types';
import { getParticipantById } from '../base/participants/functions';
import { getVideoTrackByParticipant } from '../base/tracks/functions.web';
import { SET_SEE_WHAT_IS_BEING_SHARED } from './actionTypes';
export * from './actions.any';
/**
* Captures a screenshot of the video displayed on the large video.
*
* @returns {Function}
*/
export function captureLargeVideoScreenshot() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const largeVideo = state['features/large-video'];
const promise = Promise.resolve();
if (!largeVideo?.participantId) {
return promise;
}
const participant = getParticipantById(state, largeVideo.participantId);
const participantTrack = getVideoTrackByParticipant(state, participant);
// Participants that join the call video muted do not have a jitsiTrack attached.
if (!participantTrack?.jitsiTrack) {
return promise;
}
const videoStream = participantTrack.jitsiTrack.getOriginalStream();
if (!videoStream) {
return promise;
}
// Get the video element for the large video, cast HTMLElement to HTMLVideoElement to make flow happy.
/* eslint-disable-next-line no-extra-parens */
const videoElement = document.getElementById('largeVideo') as any;
if (!videoElement) {
return promise;
}
// Create a HTML canvas and draw video on to the canvas.
const [ track ] = videoStream.getVideoTracks();
const { height, width } = track.getSettings() ?? track.getConstraints();
const canvasElement = document.createElement('canvas');
const ctx = canvasElement.getContext('2d');
canvasElement.style.display = 'none';
canvasElement.height = parseInt(height, 10);
canvasElement.width = parseInt(width, 10);
ctx?.drawImage(videoElement, 0, 0);
const dataURL = canvasElement.toDataURL('image/png', 1.0);
// Cleanup.
ctx?.clearRect(0, 0, canvasElement.width, canvasElement.height);
canvasElement.remove();
return Promise.resolve(dataURL);
};
}
/**
* Resizes the large video container based on the dimensions provided.
*
* @param {number} width - Width that needs to be applied on the large video container.
* @param {number} height - Height that needs to be applied on the large video container.
* @returns {Function}
*/
export function resizeLargeVideo(width: number, height: number) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const largeVideo = state['features/large-video'];
if (largeVideo) {
const largeVideoContainer = VideoLayout.getLargeVideo();
largeVideoContainer.updateContainerSize(width, height);
largeVideoContainer.resize();
}
};
}
/**
* Updates the value used to display what is being shared.
*
* @param {boolean} seeWhatIsBeingShared - The current value.
* @returns {{
* type: SET_SEE_WHAT_IS_BEING_SHARED,
* seeWhatIsBeingShared: boolean
* }}
*/
export function setSeeWhatIsBeingShared(seeWhatIsBeingShared: boolean) {
return {
type: SET_SEE_WHAT_IS_BEING_SHARED,
seeWhatIsBeingShared
};
}

View File

@@ -0,0 +1,266 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../app/types';
import { JitsiTrackEvents } from '../../base/lib-jitsi-meet';
import ParticipantView from '../../base/participants/components/ParticipantView.native';
import { getParticipantById, isLocalScreenshareParticipant } from '../../base/participants/functions';
import { trackStreamingStatusChanged } from '../../base/tracks/actions.native';
import { getVideoTrackByParticipant, isLocalVideoTrackDesktop } from '../../base/tracks/functions.native';
import { ITrack } from '../../base/tracks/types';
import { AVATAR_SIZE } from './styles';
/**
* The type of the React {@link Component} props of {@link LargeVideo}.
*/
interface IProps {
/**
* Whether video should be disabled.
*/
_disableVideo: boolean;
/**
* Application's viewport height.
*/
_height: number;
/**
* The ID of the participant (to be) depicted by LargeVideo.
*
* @private
*/
_participantId: string;
/**
* The video track that will be displayed in the thumbnail.
*/
_videoTrack?: ITrack;
/**
* Application's viewport height.
*/
_width: number;
/**
* Invoked to trigger state changes in Redux.
*/
dispatch: IStore['dispatch'];
/**
* Callback to invoke when the {@code LargeVideo} is clicked/pressed.
*/
onClick?: Function;
}
/**
* The type of the React {@link Component} state of {@link LargeVideo}.
*/
interface IState {
/**
* Size for the Avatar. It will be dynamically adjusted based on the
* available size.
*/
avatarSize: number;
/**
* Whether the connectivity indicator will be shown or not. It will be true
* by default, but it may be turned off if there is not enough space.
*/
useConnectivityInfoLabel: boolean;
}
const DEFAULT_STATE = {
avatarSize: AVATAR_SIZE,
useConnectivityInfoLabel: true
};
/** .
* Implements a React {@link Component} which represents the large video (a.k.a.
* The conference participant who is on the local stage) on mobile/React Native.
*
* @augments Component
*/
class LargeVideo extends PureComponent<IProps, IState> {
/**
* Creates new LargeVideo component.
*
* @param {IProps} props - The props of the component.
* @returns {LargeVideo}
*/
constructor(props: IProps) {
super(props);
this.handleTrackStreamingStatusChanged = this.handleTrackStreamingStatusChanged.bind(this);
}
state = {
...DEFAULT_STATE
};
/**
* Handles dimension changes. In case we deem it's too
* small, the connectivity indicator won't be rendered and the avatar
* will occupy the entirety of the available screen state.
*
* @inheritdoc
*/
static getDerivedStateFromProps(props: IProps) {
const { _height, _width } = props;
// Get the size, rounded to the nearest even number.
const size = 2 * Math.round(Math.min(_height, _width) / 2);
if (size < AVATAR_SIZE * 1.5) {
return {
avatarSize: size - 15, // Leave some margin.
useConnectivityInfoLabel: false
};
}
return DEFAULT_STATE;
}
/**
* Starts listening for track streaming status updates after the initial render.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
// Listen to track streaming status changed event to keep it updated.
// TODO: after converting this component to a react function component,
// use a custom hook to update local track streaming status.
const { _videoTrack, dispatch } = this.props;
if (_videoTrack && !_videoTrack.local) {
_videoTrack.jitsiTrack.on(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(_videoTrack.jitsiTrack,
_videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
}
/**
* Stops listening for track streaming status updates on the old track and starts listening instead on the new
* track.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate(prevProps: IProps) {
// TODO: after converting this component to a react function component,
// use a custom hook to update local track streaming status.
const { _videoTrack, dispatch } = this.props;
if (prevProps._videoTrack?.jitsiTrack?.getSourceName() !== _videoTrack?.jitsiTrack?.getSourceName()) {
if (prevProps._videoTrack && !prevProps._videoTrack.local) {
prevProps._videoTrack.jitsiTrack.off(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(prevProps._videoTrack.jitsiTrack,
prevProps._videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
if (_videoTrack && !_videoTrack.local) {
_videoTrack.jitsiTrack.on(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(_videoTrack.jitsiTrack,
_videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
}
}
/**
* Remove listeners for track streaming status update.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
// TODO: after converting this component to a react function component,
// use a custom hook to update local track streaming status.
const { _videoTrack, dispatch } = this.props;
if (_videoTrack && !_videoTrack.local) {
_videoTrack.jitsiTrack.off(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(_videoTrack.jitsiTrack,
_videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
}
/**
* Handle track streaming status change event by by dispatching an action to update track streaming status for the
* given track in app state.
*
* @param {JitsiTrack} jitsiTrack - The track with streaming status updated.
* @param {JitsiTrackStreamingStatus} streamingStatus - The updated track streaming status.
* @returns {void}
*/
handleTrackStreamingStatusChanged(jitsiTrack: any, streamingStatus: string) {
this.props.dispatch(trackStreamingStatusChanged(jitsiTrack, streamingStatus));
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
avatarSize,
useConnectivityInfoLabel
} = this.state;
const {
_disableVideo,
_participantId,
onClick
} = this.props;
return (
<ParticipantView
avatarSize = { avatarSize }
disableVideo = { _disableVideo }
onPress = { onClick }
participantId = { _participantId }
testHintId = 'org.jitsi.meet.LargeVideo'
useConnectivityInfoLabel = { useConnectivityInfoLabel }
zOrder = { 0 }
zoomEnabled = { true } />
);
}
}
/**
* Maps (parts of) the Redux state to the associated LargeVideo's props.
*
* @param {Object} state - Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { participantId } = state['features/large-video'];
const participant = getParticipantById(state, participantId ?? '');
const { clientHeight: height, clientWidth: width } = state['features/base/responsive-ui'];
const videoTrack = getVideoTrackByParticipant(state, participant);
let disableVideo = false;
if (isLocalScreenshareParticipant(participant)) {
disableVideo = true;
} else if (participant?.local) {
disableVideo = isLocalVideoTrackDesktop(state);
}
return {
_disableVideo: disableVideo,
_height: height,
_participantId: participantId ?? '',
_videoTrack: videoTrack,
_width: width
};
}
export default connect(_mapStateToProps)(LargeVideo);

View File

@@ -0,0 +1,406 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
// @ts-expect-error
import VideoLayout from '../../../../modules/UI/videolayout/VideoLayout';
import { IReduxState, IStore } from '../../app/types';
import { isDisplayNameVisible } from '../../base/config/functions.web';
import { VIDEO_TYPE } from '../../base/media/constants';
import { getLocalParticipant } from '../../base/participants/functions';
import Watermarks from '../../base/react/components/web/Watermarks';
import { getHideSelfView } from '../../base/settings/functions.any';
import { getVideoTrackByParticipant } from '../../base/tracks/functions.web';
import { setColorAlpha } from '../../base/util/helpers';
import { isSpotTV } from '../../base/util/spot';
import StageParticipantNameLabel from '../../display-name/components/web/StageParticipantNameLabel';
import { FILMSTRIP_BREAKPOINT } from '../../filmstrip/constants';
import { getVerticalViewMaxWidth, isFilmstripResizable } from '../../filmstrip/functions.web';
import SharedVideo from '../../shared-video/components/web/SharedVideo';
import Captions from '../../subtitles/components/web/Captions';
import { areClosedCaptionsEnabled } from '../../subtitles/functions.any';
import { setTileView } from '../../video-layout/actions.web';
import Whiteboard from '../../whiteboard/components/web/Whiteboard';
import { isWhiteboardEnabled } from '../../whiteboard/functions';
import { setSeeWhatIsBeingShared } from '../actions.web';
import { getLargeVideoParticipant } from '../functions';
import ScreenSharePlaceholder from './ScreenSharePlaceholder.web';
interface IProps {
/**
* The alpha(opacity) of the background.
*/
_backgroundAlpha?: number;
/**
* The user selected background color.
*/
_customBackgroundColor: string;
/**
* The user selected background image url.
*/
_customBackgroundImageUrl: string;
/**
* Whether the screen-sharing placeholder should be displayed or not.
*/
_displayScreenSharingPlaceholder: boolean;
/**
* Whether or not the hideSelfView is enabled.
*/
_hideSelfView: boolean;
/**
* Prop that indicates whether the chat is open.
*/
_isChatOpen: boolean;
/**
* Whether or not the display name is visible.
*/
_isDisplayNameVisible: boolean;
/**
* Whether or not the local screen share is on large-video.
*/
_isScreenSharing: boolean;
/**
* The large video participant id.
*/
_largeVideoParticipantId: string;
/**
* Local Participant id.
*/
_localParticipantId: string;
/**
* Used to determine the value of the autoplay attribute of the underlying
* video element.
*/
_noAutoPlayVideo: boolean;
/**
* Whether or not the filmstrip is resizable.
*/
_resizableFilmstrip: boolean;
/**
* Whether or not the screen sharing is visible.
*/
_seeWhatIsBeingShared: boolean;
/**
* Whether or not to show dominant speaker badge.
*/
_showDominantSpeakerBadge: boolean;
/**
* Whether or not to show subtitles button.
*/
_showSubtitles?: boolean;
/**
* The width of the vertical filmstrip (user resized).
*/
_verticalFilmstripWidth?: number | null;
/**
* The max width of the vertical filmstrip.
*/
_verticalViewMaxWidth: number;
/**
* Whether or not the filmstrip is visible.
*/
_visibleFilmstrip: boolean;
/**
* Whether or not the whiteboard is ready to be used.
*/
_whiteboardEnabled: boolean;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
}
/** .
* Implements a React {@link Component} which represents the large video (a.k.a.
* The conference participant who is on the local stage) on Web/React.
*
* @augments Component
*/
class LargeVideo extends Component<IProps> {
_tappedTimeout: number | undefined;
_containerRef: React.RefObject<HTMLDivElement>;
_wrapperRef: React.RefObject<HTMLDivElement>;
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._containerRef = React.createRef<HTMLDivElement>();
this._wrapperRef = React.createRef<HTMLDivElement>();
this._clearTapTimeout = this._clearTapTimeout.bind(this);
this._onDoubleTap = this._onDoubleTap.bind(this);
this._updateLayout = this._updateLayout.bind(this);
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
const {
_visibleFilmstrip,
_isScreenSharing,
_seeWhatIsBeingShared,
_largeVideoParticipantId,
_hideSelfView,
_localParticipantId } = this.props;
if (prevProps._visibleFilmstrip !== _visibleFilmstrip) {
this._updateLayout();
}
if (prevProps._isScreenSharing !== _isScreenSharing && !_isScreenSharing) {
this.props.dispatch(setSeeWhatIsBeingShared(false));
}
if (_isScreenSharing && _seeWhatIsBeingShared) {
VideoLayout.updateLargeVideo(_largeVideoParticipantId, true, true);
}
if (_largeVideoParticipantId === _localParticipantId
&& prevProps._hideSelfView !== _hideSelfView) {
VideoLayout.updateLargeVideo(_largeVideoParticipantId, true, false);
}
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {React$Element}
*/
override render() {
const {
_displayScreenSharingPlaceholder,
_isChatOpen,
_isDisplayNameVisible,
_noAutoPlayVideo,
_showDominantSpeakerBadge,
_whiteboardEnabled,
_showSubtitles
} = this.props;
const style = this._getCustomStyles();
const className = 'videocontainer';
return (
<div
className = { className }
id = 'largeVideoContainer'
ref = { this._containerRef }
style = { style }>
<SharedVideo />
{_whiteboardEnabled && <Whiteboard />}
<div id = 'etherpad' />
<Watermarks />
<div
id = 'dominantSpeaker'
onTouchEnd = { this._onDoubleTap }>
<div className = 'dynamic-shadow' />
<div id = 'dominantSpeakerAvatarContainer' />
</div>
<div id = 'remotePresenceMessage' />
<span id = 'remoteConnectionMessage' />
<div id = 'largeVideoElementsContainer'>
<div id = 'largeVideoBackgroundContainer' />
{/*
* FIXME: the architecture of elements related to the large
* video and the naming. The background is not part of
* largeVideoWrapper because we are controlling the size of
* the video through largeVideoWrapper. That's why we need
* another container for the background and the
* largeVideoWrapper in order to hide/show them.
*/}
{ _displayScreenSharingPlaceholder ? <ScreenSharePlaceholder /> : <></>}
<div
id = 'largeVideoWrapper'
onTouchEnd = { this._onDoubleTap }
ref = { this._wrapperRef }
role = 'figure' >
<video
autoPlay = { !_noAutoPlayVideo }
id = 'largeVideo'
muted = { true }
playsInline = { true } /* for Safari on iOS to work */ />
</div>
</div>
{ (!interfaceConfig.DISABLE_TRANSCRIPTION_SUBTITLES && _showSubtitles)
&& <Captions /> }
{
_isDisplayNameVisible
&& (
_showDominantSpeakerBadge && <StageParticipantNameLabel />
)
}
</div>
);
}
/**
* Refreshes the video layout to determine the dimensions of the stage view.
* If the filmstrip is toggled it adds CSS transition classes and removes them
* when the transition is done.
*
* @returns {void}
*/
_updateLayout() {
const { _verticalFilmstripWidth, _resizableFilmstrip } = this.props;
if (_resizableFilmstrip && Number(_verticalFilmstripWidth) >= FILMSTRIP_BREAKPOINT) {
this._containerRef.current?.classList.add('transition');
this._wrapperRef.current?.classList.add('transition');
VideoLayout.refreshLayout();
setTimeout(() => {
this._containerRef?.current && this._containerRef.current.classList.remove('transition');
this._wrapperRef?.current && this._wrapperRef.current.classList.remove('transition');
}, 1000);
} else {
VideoLayout.refreshLayout();
}
}
/**
* Clears the '_tappedTimout'.
*
* @private
* @returns {void}
*/
_clearTapTimeout() {
clearTimeout(this._tappedTimeout);
this._tappedTimeout = undefined;
}
/**
* Creates the custom styles object.
*
* @private
* @returns {Object}
*/
_getCustomStyles() {
const styles: any = {};
const {
_customBackgroundColor,
_customBackgroundImageUrl,
_verticalFilmstripWidth,
_verticalViewMaxWidth,
_visibleFilmstrip
} = this.props;
styles.background = _customBackgroundColor || interfaceConfig.DEFAULT_BACKGROUND;
if (this.props._backgroundAlpha !== undefined) {
const alphaColor = setColorAlpha(styles.backgroundColor, this.props._backgroundAlpha);
styles.background = alphaColor;
}
if (_customBackgroundImageUrl) {
styles.backgroundImage = `url(${_customBackgroundImageUrl})`;
styles.backgroundSize = 'cover';
}
if (_visibleFilmstrip && Number(_verticalFilmstripWidth) >= FILMSTRIP_BREAKPOINT) {
styles.width = `calc(100% - ${_verticalViewMaxWidth || 0}px)`;
}
return styles;
}
/**
* Sets view to tile view on double tap.
*
* @param {Object} e - The event.
* @private
* @returns {void}
*/
_onDoubleTap(e: React.TouchEvent) {
e.stopPropagation();
e.preventDefault();
if (this._tappedTimeout) {
this._clearTapTimeout();
this.props.dispatch(setTileView(true));
} else {
this._tappedTimeout = window.setTimeout(this._clearTapTimeout, 300);
}
}
}
/**
* Maps (parts of) the Redux state to the associated LargeVideo props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const testingConfig = state['features/base/config'].testing;
const { backgroundColor, backgroundImageUrl } = state['features/dynamic-branding'];
const { isOpen: isChatOpen } = state['features/chat'];
const { width: verticalFilmstripWidth, visible } = state['features/filmstrip'];
const { hideDominantSpeakerBadge } = state['features/base/config'];
const { seeWhatIsBeingShared } = state['features/large-video'];
const localParticipantId = getLocalParticipant(state)?.id;
const largeVideoParticipant = getLargeVideoParticipant(state);
const videoTrack = getVideoTrackByParticipant(state, largeVideoParticipant);
const isLocalScreenshareOnLargeVideo = largeVideoParticipant?.id?.includes(localParticipantId ?? '')
&& videoTrack?.videoType === VIDEO_TYPE.DESKTOP;
return {
_backgroundAlpha: state['features/base/config'].backgroundAlpha,
_customBackgroundColor: backgroundColor,
_customBackgroundImageUrl: backgroundImageUrl,
_displayScreenSharingPlaceholder:
Boolean(isLocalScreenshareOnLargeVideo && !seeWhatIsBeingShared && !isSpotTV(state)),
_hideSelfView: getHideSelfView(state),
_isChatOpen: isChatOpen,
_isDisplayNameVisible: isDisplayNameVisible(state),
_isScreenSharing: Boolean(isLocalScreenshareOnLargeVideo),
_largeVideoParticipantId: largeVideoParticipant?.id ?? '',
_localParticipantId: localParticipantId ?? '',
_noAutoPlayVideo: Boolean(testingConfig?.noAutoPlayVideo),
_resizableFilmstrip: isFilmstripResizable(state),
_seeWhatIsBeingShared: Boolean(seeWhatIsBeingShared),
_showDominantSpeakerBadge: !hideDominantSpeakerBadge,
_showSubtitles: areClosedCaptionsEnabled(state)
&& Boolean(state['features/base/settings'].showSubtitlesOnStage),
_verticalFilmstripWidth: verticalFilmstripWidth.current,
_verticalViewMaxWidth: getVerticalViewMaxWidth(state),
_visibleFilmstrip: visible,
_whiteboardEnabled: isWhiteboardEnabled(state)
};
}
export default connect(_mapStateToProps)(LargeVideo);

View File

@@ -0,0 +1,260 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../app/types';
import { shouldDisplayTileView } from '../../video-layout/functions.web';
/**
* Constants to describe the dimensions of the video. Landscape videos
* are wider than they are taller and portrait videos are taller than they
* are wider. The dimensions will determine how {@code LargeVideoBackground}
* will stretch to fill its container.
*
* @type {Object}
*/
export const ORIENTATION = {
LANDSCAPE: 'landscape',
PORTRAIT: 'portrait'
};
/**
* The type of the React {@code Component} props of
* {@link LargeVideoBackgroundCanvas}.
*/
interface IProps {
/**
* Whether or not the layout should change to support tile view mode.
*
* @protected
* @type {boolean}
*/
_shouldDisplayTileView: boolean;
/**
* Additional CSS class names to add to the root of the component.
*/
className: String;
/**
* Whether or not the background should have its visibility hidden.
*/
hidden: boolean;
/**
* Whether or not the video should display flipped horizontally, so left
* becomes right and right becomes left.
*/
mirror: boolean;
/**
* Whether the component should ensure full width of the video is displayed
* (landscape) or full height (portrait).
*/
orientationFit: string;
/**
* The video stream to display.
*/
videoElement: HTMLVideoElement;
}
/**
* Implements a React Component which shows a video element intended to be used
* as a background to fill the empty space of container with another video.
*
* @augments Component
*/
export class LargeVideoBackground extends Component<IProps> {
_canvasEl: HTMLCanvasElement;
_updateCanvasInterval: number | undefined;
/**
* Initializes new {@code LargeVideoBackground} instance.
*
* @param {*} props - The read-only properties with which the new instance
* is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._setCanvasEl = this._setCanvasEl.bind(this);
this._updateCanvas = this._updateCanvas.bind(this);
}
/**
* If the canvas is not hidden, sets the initial interval to update the
* image displayed in the canvas.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const { _shouldDisplayTileView, hidden, videoElement } = this.props;
if (videoElement && !hidden && !_shouldDisplayTileView) {
this._updateCanvas();
this._setUpdateCanvasInterval();
}
}
/**
* Starts or stops the interval to update the image displayed in the canvas.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
const wasCanvasUpdating = !prevProps.hidden && !prevProps._shouldDisplayTileView && prevProps.videoElement;
const shouldCanvasUpdating
= !this.props.hidden && !this.props._shouldDisplayTileView && this.props.videoElement;
if (wasCanvasUpdating !== shouldCanvasUpdating) {
if (shouldCanvasUpdating) {
this._clearCanvas();
this._setUpdateCanvasInterval();
} else {
this._clearCanvas();
this._clearUpdateCanvasInterval();
}
}
}
/**
* Clears the interval for updating the image displayed in the canvas.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._clearUpdateCanvasInterval();
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
hidden,
mirror
} = this.props;
const classNames = `large-video-background ${mirror ? 'flip-x' : ''} ${hidden ? 'invisible' : ''}`;
return (
<div className = { classNames }>
<canvas
id = 'largeVideoBackground'
ref = { this._setCanvasEl } />
</div>
);
}
/**
* Removes any image displayed on the canvas.
*
* @private
* @returns {void}
*/
_clearCanvas() {
const cavnasContext = this._canvasEl.getContext('2d');
cavnasContext?.clearRect(
0, 0, this._canvasEl.width, this._canvasEl.height);
}
/**
* Clears the interval for updating the image displayed in the canvas.
*
* @private
* @returns {void}
*/
_clearUpdateCanvasInterval() {
clearInterval(this._updateCanvasInterval);
}
/**
* Sets the instance variable for the component's canvas element so it can
* be accessed directly for drawing on.
*
* @param {Object} element - The DOM element for the component's canvas.
* @private
* @returns {void}
*/
_setCanvasEl(element: HTMLCanvasElement) {
this._canvasEl = element;
}
/**
* Starts the interval for updating the image displayed in the canvas.
*
* @private
* @returns {void}
*/
_setUpdateCanvasInterval() {
this._clearUpdateCanvasInterval();
this._updateCanvasInterval = window.setInterval(this._updateCanvas, 200);
}
/**
* Draws the current frame of the passed in video element onto the canvas.
*
* @private
* @returns {void}
*/
_updateCanvas() {
// On Electron 7 there is a memory leak if we try to draw into a hidden canvas that is part of the DOM tree.
// See: https://github.com/electron/electron/issues/22417
// Trying to detect all the cases when the page will be hidden because of something not in our control
// (for example when the page is loaded in an iframe which is hidden due to the host page styles) to solve
// the memory leak. Currently we are not handling the use case when the page is hidden with visibility:hidden
// because we don't have a good way to do it.
// All other cases when the canvas is not visible are handled through the component props
// (hidden, _shouldDisplayTileView).
if (!this._canvasEl?.offsetParent || window.innerHeight === 0 || window.innerWidth === 0) {
return;
}
const { videoElement } = this.props;
const { videoWidth, videoHeight } = videoElement;
const {
height: canvasHeight,
width: canvasWidth
} = this._canvasEl;
const canvasContext = this._canvasEl.getContext('2d');
if (this.props.orientationFit === ORIENTATION.LANDSCAPE) {
const heightScaledToFit = (canvasWidth / videoWidth) * videoHeight;
canvasContext?.drawImage(
videoElement as any, 0, 0, canvasWidth, heightScaledToFit);
} else {
const widthScaledToFit = (canvasHeight / videoHeight) * videoWidth;
canvasContext?.drawImage(
videoElement as any, 0, 0, widthScaledToFit, canvasHeight);
}
}
}
/**
* Maps (parts of) the Redux state to the associated LargeVideoBackground props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _shouldDisplayTileView: boolean
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
_shouldDisplayTileView: shouldDisplayTileView(state)
};
}
export default connect(_mapStateToProps)(LargeVideoBackground);

View File

@@ -0,0 +1,100 @@
import React, { useCallback } from 'react';
import { WithTranslation } from 'react-i18next';
import { useStore } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { translate } from '../../base/i18n/functions';
import { setSeeWhatIsBeingShared } from '../actions.web';
const useStyles = makeStyles()(theme => {
return {
overlayContainer: {
width: '100%',
height: '100%',
backgroundColor: theme.palette.ui02,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
top: 0,
left: 0,
zIndex: 2
},
content: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
},
laptop: {
width: '88px',
height: '56px',
boxSizing: 'border-box',
border: '3px solid',
borderColor: theme.palette.text01,
borderRadius: '6px'
},
laptopStand: {
width: '40px',
height: '4px',
backgroundColor: theme.palette.text01,
boxSizing: 'border-box',
borderRadius: '6px',
marginTop: '4px'
},
sharingMessage: {
fontStyle: 'normal',
fontWeight: 600,
fontSize: '1.25rem',
lineHeight: '1.75rem',
marginTop: '24px',
letterSpacing: '-0.012em',
color: theme.palette.text01
},
showSharing: {
fontStyle: 'normal',
fontWeight: 600,
fontSize: '0.875rem',
lineHeight: '1.25rem',
height: '20px',
marginTop: '16px',
color: theme.palette.link01,
cursor: 'pointer',
'&:hover': {
color: theme.palette.link01Hover
}
}
};
});
/**
* Component that displays a placeholder for when the screen is shared.
* * @param {Function} t - Function which translate strings.
*
* @returns {ReactElement}
*/
const ScreenSharePlaceholder: React.FC<WithTranslation> = ({ t }) => {
const { classes } = useStyles();
const store = useStore();
const updateShowMeWhatImSharing = useCallback(() => {
store.dispatch(setSeeWhatIsBeingShared(true));
}, []);
return (
<div className = { classes.overlayContainer }>
<div className = { classes.content }>
<div className = { classes.laptop } />
<div className = { classes.laptopStand } />
<span className = { classes.sharingMessage }>{ t('largeVideo.screenIsShared') }</span>
<span
className = { classes.showSharing }
onClick = { updateShowMeWhatImSharing }
role = 'button'>{ t('largeVideo.showMeWhatImSharing') }</span>
</div>
</div>
);
};
export default translate(ScreenSharePlaceholder);

View File

@@ -0,0 +1,4 @@
/**
* Size for the Avatar.
*/
export const AVATAR_SIZE = 200;

View File

@@ -0,0 +1,14 @@
import { IReduxState } from '../app/types';
import { getParticipantById } from '../base/participants/functions';
/**
* Selector for the participant currently displaying on the large video.
*
* @param {Object} state - The redux state.
* @returns {Object}
*/
export function getLargeVideoParticipant(state: IReduxState) {
const { participantId } = state['features/large-video'];
return getParticipantById(state, participantId ?? '');
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/large-video');

View File

@@ -0,0 +1,74 @@
import {
DOMINANT_SPEAKER_CHANGED,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PIN_PARTICIPANT
} from '../base/participants/actionTypes';
import { getDominantSpeakerParticipant, getLocalParticipant } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { isTestModeEnabled } from '../base/testing/functions';
import {
TRACK_ADDED,
TRACK_REMOVED
} from '../base/tracks/actionTypes';
import { TOGGLE_DOCUMENT_EDITING } from '../etherpad/actionTypes';
import { selectParticipantInLargeVideo } from './actions';
import logger from './logger';
import './subscriber';
/**
* Middleware that catches actions related to participants and tracks and
* dispatches an action to select a participant depicted by LargeVideo.
*
* @param {Store} store - Redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case DOMINANT_SPEAKER_CHANGED: {
const state = store.getState();
const localParticipant = getLocalParticipant(state);
const dominantSpeaker = getDominantSpeakerParticipant(state);
if (dominantSpeaker?.id === action.participant.id) {
return next(action);
}
const result = next(action);
if (isTestModeEnabled(state)) {
logger.info(`Dominant speaker changed event for: ${action.participant.id}`);
}
if (localParticipant && localParticipant.id !== action.participant.id) {
store.dispatch(selectParticipantInLargeVideo());
}
return result;
}
case PIN_PARTICIPANT: {
const result = next(action);
store.dispatch(selectParticipantInLargeVideo(action.participant?.id));
return result;
}
case PARTICIPANT_JOINED:
case PARTICIPANT_LEFT:
case TOGGLE_DOCUMENT_EDITING:
case TRACK_ADDED:
case TRACK_REMOVED: {
const result = next(action);
store.dispatch(selectParticipantInLargeVideo());
return result;
}
}
const result = next(action);
return result;
});

View File

@@ -0,0 +1,64 @@
import { PARTICIPANT_ID_CHANGED } from '../base/participants/actionTypes';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
SELECT_LARGE_VIDEO_PARTICIPANT,
SET_LARGE_VIDEO_DIMENSIONS,
SET_SEE_WHAT_IS_BEING_SHARED,
UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION
} from './actionTypes';
export interface ILargeVideoState {
height?: number;
participantId?: string;
resolution?: number;
seeWhatIsBeingShared?: boolean;
width?: number;
}
ReducerRegistry.register<ILargeVideoState>('features/large-video', (state = {}, action): ILargeVideoState => {
switch (action.type) {
// When conference is joined, we update ID of local participant from default
// 'local' to real ID. However, in large video we might have already
// selected 'local' as participant on stage. So in this case we must update
// ID of participant on stage to match ID in 'participants' state to avoid
// additional changes in state and (re)renders.
case PARTICIPANT_ID_CHANGED:
if (state.participantId === action.oldValue) {
return {
...state,
participantId: action.newValue
};
}
break;
case SELECT_LARGE_VIDEO_PARTICIPANT:
return {
...state,
participantId: action.participantId
};
case SET_LARGE_VIDEO_DIMENSIONS:
return {
...state,
height: action.height,
width: action.width
};
case UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION:
return {
...state,
resolution: action.resolution
};
case SET_SEE_WHAT_IS_BEING_SHARED:
return {
...state,
seeWhatIsBeingShared: action.seeWhatIsBeingShared
};
}
return state;
});

View File

@@ -0,0 +1,38 @@
// @ts-expect-error
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { getVideoTrackByParticipant } from '../base/tracks/functions.web';
import { getLargeVideoParticipant } from './functions';
/**
* Updates the on stage participant video.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/large-video'].participantId,
/* listener */ participantId => {
VideoLayout.updateLargeVideo(participantId, true);
}
);
/**
* Schedules a large video update when the streaming status of the track associated with the large video changes.
*/
StateListenerRegistry.register(
/* selector */ state => {
const largeVideoParticipant = getLargeVideoParticipant(state);
const videoTrack = getVideoTrackByParticipant(state, largeVideoParticipant);
return {
participantId: largeVideoParticipant?.id,
streamingStatus: videoTrack?.streamingStatus
};
},
/* listener */ ({ participantId, streamingStatus }, previousState: any = {}) => {
if (streamingStatus !== previousState.streamingStatus) {
VideoLayout.updateLargeVideo(participantId, true);
}
}, {
deepEquals: true
}
);