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,38 @@
/**
* The type of the action which signals to update the current known state of the
* shared video.
*
* {
* type: SET_SHARED_VIDEO_STATUS,
* status: string
* }
*/
export const SET_SHARED_VIDEO_STATUS = 'SET_SHARED_VIDEO_STATUS';
/**
* The type of the action which signals to reset the current known state of the
* shared video.
*
* {
* type: RESET_SHARED_VIDEO_STATUS,
* }
*/
export const RESET_SHARED_VIDEO_STATUS = 'RESET_SHARED_VIDEO_STATUS';
/**
* The type of the action which marks that the user had confirmed to play video.
*
* {
* type: SET_CONFIRM_SHOW_VIDEO
* }
*/
export const SET_CONFIRM_SHOW_VIDEO = 'SET_CONFIRM_SHOW_VIDEO';
/**
* The type of the action which sets an array of whitelisted urls.
*
* {
* type: SET_ALLOWED_URL_DOMAINS
* }
*/
export const SET_ALLOWED_URL_DOMAINS = 'SET_ALLOWED_URL_DOMAINS';

View File

@@ -0,0 +1,203 @@
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { hideDialog, openDialog } from '../base/dialog/actions';
import { getLocalParticipant } from '../base/participants/functions';
import {
RESET_SHARED_VIDEO_STATUS,
SET_ALLOWED_URL_DOMAINS,
SET_CONFIRM_SHOW_VIDEO,
SET_SHARED_VIDEO_STATUS
} from './actionTypes';
import { ShareVideoConfirmDialog, SharedVideoDialog } from './components';
import { PLAYBACK_START, PLAYBACK_STATUSES } from './constants';
import { isSharedVideoEnabled, sendShareVideoCommand } from './functions';
/**
* Marks that user confirmed or not to play video.
*
* @param {boolean} value - The value to set.
* @returns {{
* type: SET_CONFIRM_SHOW_VIDEO,
* }}
*/
export function setConfirmShowVideo(value: boolean) {
return {
type: SET_CONFIRM_SHOW_VIDEO,
value
};
}
/**
* Resets the status of the shared video.
*
* @returns {{
* type: SET_SHARED_VIDEO_STATUS,
* }}
*/
export function resetSharedVideoStatus() {
return {
type: RESET_SHARED_VIDEO_STATUS
};
}
/**
* Updates the current known status of the shared video.
*
* @param {Object} options - The options.
* @param {boolean} options.muted - Is video muted.
* @param {boolean} options.ownerId - Participant ID of the owner.
* @param {boolean} options.status - Sharing status.
* @param {boolean} options.time - Playback timestamp.
* @param {boolean} options.videoUrl - URL of the shared video.
*
* @returns {{
* type: SET_SHARED_VIDEO_STATUS,
* muted: boolean,
* ownerId: string,
* status: string,
* time: number,
* videoUrl: string,
* }}
*/
export function setSharedVideoStatus({ videoUrl, status, time, ownerId, muted }: {
muted?: boolean; ownerId?: string; status: string; time: number; videoUrl: string;
}) {
return {
type: SET_SHARED_VIDEO_STATUS,
ownerId,
status,
time,
videoUrl,
muted
};
}
/**
* Displays the dialog for entering the video link.
*
* @param {Function} onPostSubmit - The function to be invoked when a valid link is entered.
* @returns {Function}
*/
export function showSharedVideoDialog(onPostSubmit: Function) {
return openDialog(SharedVideoDialog, { onPostSubmit });
}
/**
*
* Stops playing a shared video.
*
* @returns {Function}
*/
export function stopSharedVideo() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { ownerId } = state['features/shared-video'];
const localParticipant = getLocalParticipant(state);
if (ownerId === localParticipant?.id) {
dispatch(resetSharedVideoStatus());
}
};
}
/**
*
* Plays a shared video.
*
* @param {string} videoUrl - The video url to be played.
*
* @returns {Function}
*/
export function playSharedVideo(videoUrl: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!isSharedVideoEnabled(getState())) {
return;
}
const conference = getCurrentConference(getState());
if (conference) {
const localParticipant = getLocalParticipant(getState());
// we will send the command and will create local video fake participant
// and start playing once we receive ourselves the command
sendShareVideoCommand({
conference,
id: videoUrl,
localParticipantId: localParticipant?.id,
status: PLAYBACK_START,
time: 0
});
}
};
}
/**
*
* Stops playing a shared video.
*
* @returns {Function}
*/
export function toggleSharedVideo() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { status = '' } = state['features/shared-video'];
if ([ PLAYBACK_STATUSES.PLAYING, PLAYBACK_START, PLAYBACK_STATUSES.PAUSED ].includes(status)) {
dispatch(stopSharedVideo());
} else {
dispatch(showSharedVideoDialog((id: string) => dispatch(playSharedVideo(id))));
}
};
}
/**
* Sets the allowed URL domains of the shared video.
*
* @param {Array<string>} allowedUrlDomains - The new whitelist to be set.
* @returns {{
* type: SET_ALLOWED_URL_DOMAINS,
* allowedUrlDomains: Array<string>
* }}
*/
export function setAllowedUrlDomians(allowedUrlDomains: Array<string>) {
return {
type: SET_ALLOWED_URL_DOMAINS,
allowedUrlDomains
};
}
/**
* Shows a confirmation dialog whether to play the external video link.
*
* @param {string} actor - The actor's name.
* @param {Function} onSubmit - The function to execute when confirmed.
*
* @returns {Function}
*/
export function showConfirmPlayingDialog(actor: String, onSubmit: Function) {
return (dispatch: IStore['dispatch']) => {
// shows only one dialog at a time
dispatch(setConfirmShowVideo(false));
dispatch(openDialog(ShareVideoConfirmDialog, {
actorName: actor,
onSubmit: () => {
dispatch(setConfirmShowVideo(true));
onSubmit();
}
}));
};
}
/**
* Hides the video play confirmation dialog.
*
* @returns {Function}
*/
export function hideConfirmPlayingDialog() {
return (dispatch: IStore['dispatch']) => {
dispatch(hideDialog(ShareVideoConfirmDialog));
};
}

View File

@@ -0,0 +1,68 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { IStore } from '../../app/types';
import { extractYoutubeIdOrURL } from '../functions';
/**
* The type of the React {@code Component} props of
* {@link AbstractSharedVideoDialog}.
*/
export interface IProps extends WithTranslation {
/**
* The allowed URL domains for shared video.
*/
_allowedUrlDomains: Array<string>;
/**
* Invoked to update the shared video link.
*/
dispatch: IStore['dispatch'];
/**
* Function to be invoked after typing a valid video.
*/
onPostSubmit: Function;
}
/**
* Implements an abstract class for {@code SharedVideoDialog}.
*/
export default class AbstractSharedVideoDialog<S> extends Component < IProps, S > {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onSetVideoLink = this._onSetVideoLink.bind(this);
}
/**
* Validates the entered video link by extracting the id and dispatches it.
*
* It returns a boolean to comply the Dialog behaviour:
* {@code true} - the dialog should be closed.
* {@code false} - the dialog should be left open.
*
* @param {string} link - The entered video link.
* @returns {boolean}
*/
_onSetVideoLink(link: string) {
const { onPostSubmit } = this.props;
const id = extractYoutubeIdOrURL(link);
if (!id) {
return false;
}
onPostSubmit(id);
return true;
}
}

View File

@@ -0,0 +1,4 @@
// @ts-ignore
export { default as SharedVideoDialog } from './native/SharedVideoDialog';
export { default as SharedVideoButton } from './native/SharedVideoButton';
export { default as ShareVideoConfirmDialog } from './native/ShareVideoConfirmDialog';

View File

@@ -0,0 +1,3 @@
export { default as SharedVideoDialog } from './web/SharedVideoDialog';
export { default as SharedVideoButton } from './web/SharedVideoButton';
export { default as ShareVideoConfirmDialog } from './web/ShareVideoConfirmDialog';

View File

@@ -0,0 +1,266 @@
import { throttle } from 'lodash-es';
import { PureComponent } from 'react';
import { IReduxState, IStore } from '../../../app/types';
import { getCurrentConference } from '../../../base/conference/functions';
import { IJitsiConference } from '../../../base/conference/reducer';
import { getLocalParticipant } from '../../../base/participants/functions';
import { setSharedVideoStatus } from '../../actions';
import { PLAYBACK_STATUSES } from '../../constants';
/**
* Return true if the difference between the two times is larger than 5.
*
* @param {number} newTime - The current time.
* @param {number} previousTime - The previous time.
* @private
* @returns {boolean}
*/
function shouldSeekToPosition(newTime: number, previousTime: number) {
return Math.abs(newTime - previousTime) > 5;
}
/**
* The type of the React {@link Component} props of {@link AbstractVideoManager}.
*/
export interface IProps {
/**
* The current conference.
*/
_conference?: IJitsiConference;
/**
* Is the video shared by the local user.
*
* @private
*/
_isOwner: boolean;
/**
* The shared video owner id.
*/
_ownerId?: string;
/**
* The shared video status.
*/
_status?: string;
/**
* Seek time in seconds.
*
*/
_time: number;
/**
* The video url.
*/
_videoUrl?: string;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The player's height.
*/
height: number;
/**
* The video id.
*/
videoId: string;
/**
* The player's width.
*/
width: number;
}
/**
* Manager of shared video.
*/
abstract class AbstractVideoManager<S=void> extends PureComponent<IProps, S> {
throttledFireUpdateSharedVideoEvent: Function;
/**
* Initializes a new instance of AbstractVideoManager.
*
* @param {IProps} props - Component props.
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.throttledFireUpdateSharedVideoEvent = throttle(this.fireUpdateSharedVideoEvent.bind(this), 5000);
}
/**
* Implements React Component's componentDidMount.
*
* @inheritdoc
*/
override componentDidMount() {
this.processUpdatedProps();
}
/**
* Implements React Component's componentDidUpdate.
*
* @inheritdoc
*/
override componentDidUpdate() {
this.processUpdatedProps();
}
/**
* Implements React Component's componentWillUnmount.
*
* @inheritdoc
*/
override componentWillUnmount() {
if (this.dispose) {
this.dispose();
}
}
/**
* Processes new properties.
*
* @returns {void}
*/
async processUpdatedProps() {
const { _status, _time, _isOwner } = this.props;
if (_isOwner) {
return;
}
const playerTime = await this.getTime();
if (shouldSeekToPosition(_time, playerTime)) {
this.seek(_time);
}
if (this.getPlaybackStatus() !== _status) {
if (_status === PLAYBACK_STATUSES.PLAYING) {
this.play();
} else if (_status === PLAYBACK_STATUSES.PAUSED) {
this.pause();
}
}
}
/**
* Handle video playing.
*
* @returns {void}
*/
onPlay() {
this.fireUpdateSharedVideoEvent();
}
/**
* Handle video paused.
*
* @returns {void}
*/
onPause() {
this.fireUpdateSharedVideoEvent();
}
/**
* Dispatches an update action for the shared video.
*
* @returns {void}
*/
async fireUpdateSharedVideoEvent() {
const { _isOwner } = this.props;
if (!_isOwner) {
return;
}
const status = this.getPlaybackStatus();
if (!Object.values(PLAYBACK_STATUSES).includes(status)) {
return;
}
const time = await this.getTime();
const {
_ownerId,
_videoUrl,
dispatch
} = this.props;
dispatch(setSharedVideoStatus({
videoUrl: _videoUrl ?? '',
status,
time,
ownerId: _ownerId
}));
}
/**
* Seeks video to provided time.
*/
abstract seek(time: number): void;
/**
* Indicates the playback state of the video.
*/
abstract getPlaybackStatus(): string;
/**
* Plays video.
*/
abstract play(): void;
/**
* Pauses video.
*
* @returns {void}
*/
abstract pause(): void;
/**
* Retrieves current time.
*/
abstract getTime(): number;
/**
* Disposes current video player.
*
* @returns {void}
*/
dispose() {
// optional abstract method to be implemented by sub-class
}
}
export default AbstractVideoManager;
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState) {
const { ownerId, status, time, videoUrl } = state['features/shared-video'];
const localParticipant = getLocalParticipant(state);
return {
_conference: getCurrentConference(state),
_isOwner: ownerId === localParticipant?.id,
_ownerId: ownerId,
_status: status,
_time: Number(time),
_videoUrl: videoUrl
};
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { DialogProps } from '../../../base/dialog/constants';
interface IProps extends DialogProps {
/**
* The name of the remote participant that shared the video.
*/
actorName: string;
/**
* The function to execute when confirmed.
*/
onSubmit: () => void;
}
/**
* Dialog to confirm playing a video shared from a remote participant.
*
* @returns {JSX.Element}
*/
export default function ShareVideoConfirmDialog({ actorName, onSubmit }: IProps): JSX.Element {
const { t } = useTranslation();
return (
<ConfirmDialog
cancelLabel = 'dialog.Cancel'
confirmLabel = 'dialog.Ok'
descriptionKey = 'dialog.shareVideoConfirmPlay'
onSubmit = { onSubmit }
title = { t('dialog.shareVideoConfirmPlayTitle', {
name: actorName
}) } />
);
}

View File

@@ -0,0 +1,167 @@
import React, { Component } from 'react';
import { View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { getLocalParticipant } from '../../../base/participants/functions';
import { ASPECT_RATIO_WIDE } from '../../../base/responsive-ui/constants';
import { setToolboxVisible } from '../../../toolbox/actions';
import VideoManager from './VideoManager';
import YoutubeVideoManager from './YoutubeVideoManager';
import styles from './styles';
interface IProps {
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Is the video shared by the local user.
*
* @private
*/
isOwner: boolean;
/**
* True if in landscape mode.
*
* @private
*/
isWideScreen: boolean;
/**
* The available player width.
*/
playerHeight: number;
/**
* The available player width.
*/
playerWidth: number;
/**
* The shared video url.
*/
videoUrl?: string;
}
/** .
* 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 SharedVideo extends Component<IProps> {
/**
* Initializes a new {@code SharedVideo} instance.
*
* @param {Object} props - The properties.
*/
constructor(props: IProps) {
super(props);
this.setWideScreenMode(props.isWideScreen);
}
/**
* Implements React's {@link Component#componentDidUpdate()}.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate(prevProps: IProps) {
const { isWideScreen } = this.props;
if (isWideScreen !== prevProps.isWideScreen) {
this.setWideScreenMode(isWideScreen);
}
}
/**
* Dispatches action to set the visibility of the toolbox, true if not widescreen, false otherwise.
*
* @param {isWideScreen} isWideScreen - Whether the screen is wide.
* @private
* @returns {void}
*/
setWideScreenMode(isWideScreen: boolean) {
this.props.dispatch(setToolboxVisible(!isWideScreen));
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {React$Element}
*/
override render() {
const {
isOwner,
playerHeight,
playerWidth,
videoUrl
} = this.props;
if (!videoUrl) {
return null;
}
return (
<View
pointerEvents = { isOwner ? 'auto' : 'none' }
style = { styles.videoContainer as ViewStyle } >
{videoUrl.match(/http/)
? (
<VideoManager
height = { playerHeight }
videoId = { videoUrl }
width = { playerWidth } />
) : (
<YoutubeVideoManager
height = { playerHeight }
videoId = { videoUrl }
width = { playerWidth } />
)
}
</View>
);
}
}
/**
* 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 { ownerId, videoUrl } = state['features/shared-video'];
const { aspectRatio, clientHeight, clientWidth } = state['features/base/responsive-ui'];
const isWideScreen = aspectRatio === ASPECT_RATIO_WIDE;
const localParticipant = getLocalParticipant(state);
let playerHeight, playerWidth;
if (isWideScreen) {
playerHeight = clientHeight;
playerWidth = playerHeight * 16 / 9;
} else {
playerWidth = clientWidth;
playerHeight = playerWidth * 9 / 16;
}
return {
isOwner: ownerId === localParticipant?.id,
isWideScreen,
playerHeight,
playerWidth,
videoUrl
};
}
export default connect(_mapStateToProps)(SharedVideo);

View File

@@ -0,0 +1,113 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { VIDEO_SHARE_BUTTON_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { IconPlay } from '../../../base/icons/svg';
import { getLocalParticipant } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { toggleSharedVideo } from '../../actions';
import { isSharingStatus } from '../../functions';
/**
* The type of the React {@code Component} props of {@link TileViewButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether or not the button is disabled.
*/
_isDisabled: boolean;
/**
* Whether or not the local participant is sharing a video.
*/
_sharingVideo: boolean;
}
/**
* Component that renders a toolbar button for toggling the tile layout view.
*
* @augments AbstractButton
*/
class VideoShareButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.sharedvideo';
override icon = IconPlay;
override label = 'toolbar.sharedvideo';
override toggledLabel = 'toolbar.stopSharedVideo';
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
override _handleClick() {
this._doToggleSharedVideo();
}
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._sharingVideo;
}
/**
* Indicates whether this button is disabled or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isDisabled() {
return this.props._isDisabled;
}
/**
* Dispatches an action to toggle video sharing.
*
* @private
* @returns {void}
*/
_doToggleSharedVideo() {
this.props.dispatch(toggleSharedVideo());
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The properties explicitly passed to the component instance.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { ownerId, status: sharedVideoStatus } = state['features/shared-video'];
const localParticipantId = getLocalParticipant(state)?.id;
const enabled = getFeatureFlag(state, VIDEO_SHARE_BUTTON_ENABLED, true);
const { visible = enabled } = ownProps;
if (ownerId !== localParticipantId) {
return {
_isDisabled: isSharingStatus(sharedVideoStatus ?? ''),
_sharingVideo: false,
visible
};
}
return {
_isDisabled: false,
_sharingVideo: isSharingStatus(sharedVideoStatus ?? ''),
visible
};
}
export default translate(connect(_mapStateToProps)(VideoShareButton));

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import InputDialog from '../../../base/dialog/components/native/InputDialog';
import { translate } from '../../../base/i18n/functions';
import AbstractSharedVideoDialog, { IProps } from '../AbstractSharedVideoDialog';
interface IState {
error: boolean;
}
/**
* Implements a component to render a display name prompt.
*/
class SharedVideoDialog extends AbstractSharedVideoDialog<IState> {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
error: false
};
this._onSubmitValue = this._onSubmitValue.bind(this);
}
/**
* Callback to be invoked when the value of the link input is submitted.
*
* @param {string} value - The entered video link.
* @returns {boolean}
*/
_onSubmitValue(value: string) {
const result = super._onSetVideoLink(value);
if (!result) {
this.setState({ error: true });
}
return result;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { t } = this.props;
const { error } = this.state;
return (
<InputDialog
messageKey = { error ? 'dialog.sharedVideoDialogError' : undefined }
onSubmit = { this._onSubmitValue }
textInputProps = {{
autoCapitalize: 'none',
autoCorrect: false,
placeholder: t('dialog.sharedVideoLinkPlaceholder')
}}
titleKey = 'dialog.shareVideoTitle' />
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
const { allowedUrlDomains } = state['features/shared-video'];
return {
_allowedUrlDomains: allowedUrlDomains
};
}
export default translate(connect(mapStateToProps)(SharedVideoDialog));

View File

@@ -0,0 +1,196 @@
import React, { RefObject } from 'react';
import Video from 'react-native-video';
import { connect } from 'react-redux';
import { PLAYBACK_STATUSES } from '../../constants';
import logger from '../../logger';
import AbstractVideoManager, {
IProps,
_mapStateToProps
} from './AbstractVideoManager';
interface IState {
currentTime: number;
paused: boolean;
}
/**
* Manager of shared video.
*/
class VideoManager extends AbstractVideoManager<IState> {
playerRef: RefObject<typeof Video>;
/**
* Initializes a new VideoManager instance.
*
* @param {Object} props - This component's props.
*
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.state = {
currentTime: 0,
paused: false
};
this.playerRef = React.createRef();
this.onPlaybackRateChange = this.onPlaybackRateChange.bind(this);
this.onProgress = this.onProgress.bind(this);
}
/**
* Retrieves the current player ref.
*/
get player() {
return this.playerRef.current;
}
/**
* Indicates the playback state of the video.
*
* @returns {string}
*/
getPlaybackStatus() {
let status;
if (this.state.paused) {
status = PLAYBACK_STATUSES.PAUSED;
} else {
status = PLAYBACK_STATUSES.PLAYING;
}
return status;
}
/**
* Retrieves current time.
*
* @returns {number}
*/
getTime() {
return this.state.currentTime;
}
/**
* Seeks video to provided time.
*
* @param {number} time - The time to seek to.
*
* @returns {void}
*/
seek(time: number) {
if (this.player) {
// @ts-ignore
this.player.seek(time);
}
}
/**
* Plays video.
*
* @returns {void}
*/
play() {
this.setState({
paused: false
});
}
/**
* Pauses video.
*
* @returns {void}
*/
pause() {
this.setState({
paused: true
});
}
/**
* Handles playback rate changed event.
*
* @param {Object} options.playbackRate - Playback rate: 1 - playing, 0 - paused, other - slowed down / sped up.
* @returns {void}
*/
onPlaybackRateChange({ playbackRate }: { playbackRate: number; }) {
if (playbackRate === 0) {
this.setState({
paused: true
}, () => {
this.onPause();
});
}
if (playbackRate === 1) {
this.setState({
paused: false
}, () => {
this.onPlay();
});
}
}
/**
* Handles progress update event.
*
* @param {Object} options - Progress event options.
* @returns {void}
*/
onProgress(options: { currentTime: number; }) {
this.setState({ currentTime: options.currentTime });
this.throttledFireUpdateSharedVideoEvent();
}
/**
* Retrieves video tag params.
*
* @returns {void}
*/
getPlayerOptions() {
const { _isOwner, videoId, width, height } = this.props;
const { paused } = this.state;
const options: any = {
paused,
progressUpdateInterval: 5000,
resizeMode: 'cover' as const,
style: {
height,
width
},
source: { uri: videoId },
controls: _isOwner,
pictureInPicture: false,
onProgress: this.onProgress,
onError: (event: Error) => {
logger.error('Error in the player:', event);
}
};
if (_isOwner) {
options.onPlaybackRateChange = this.onPlaybackRateChange;
}
return options;
}
/**
* Implements React Component's render.
*
* @inheritdoc
*/
override render() {
return (
<Video
ref = { this.playerRef }
{ ...this.getPlayerOptions() } />
);
}
}
export default connect(_mapStateToProps)(VideoManager);

View File

@@ -0,0 +1,203 @@
import React, { RefObject } from 'react';
import Video from 'react-native-youtube-iframe';
import { connect } from 'react-redux';
import { PLAYBACK_STATUSES } from '../../constants';
import AbstractVideoManager, {
IProps,
_mapStateToProps
} from './AbstractVideoManager';
/**
* Passed to the webviewProps in order to avoid the usage of the ios player on which we cannot hide the controls.
*
* @private
*/
const webviewUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36'; // eslint-disable-line max-len
interface IState {
paused: boolean;
}
/**
* Manager of youtube shared video.
*/
class YoutubeVideoManager extends AbstractVideoManager<IState> {
playerRef: RefObject<typeof Video>;
/**
* Initializes a new VideoManager instance.
*
* @param {Object} props - This component's props.
*
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.state = {
paused: false
};
this.playerRef = React.createRef();
this._onReady = this._onReady.bind(this);
this._onChangeState = this._onChangeState.bind(this);
}
/**
* Retrieves the current player ref.
*/
get player() {
return this.playerRef.current;
}
/**
* Indicates the playback state of the video.
*
* @returns {string}
*/
getPlaybackStatus() {
let status;
if (this.state.paused) {
status = PLAYBACK_STATUSES.PAUSED;
} else {
status = PLAYBACK_STATUSES.PLAYING;
}
return status;
}
/**
* Retrieves current time.
*
* @returns {number}
*/
getTime() {
// @ts-ignore
return this.player?.getCurrentTime();
}
/**
* Seeks video to provided time.
*
* @param {number} time - The time to seek to.
*
* @returns {void}
*/
seek(time: number) {
if (this.player) {
// @ts-ignore
this.player.seekTo(time);
}
}
/**
* Plays video.
*
* @returns {void}
*/
play() {
this.setState({
paused: false
});
}
/**
* Pauses video.
*
* @returns {void}
*/
pause() {
this.setState({
paused: true
});
}
/**
* Handles state change event.
*
* @param {string} event - State event.
* @returns {void}
*/
_onChangeState(event: string) {
if (event === 'paused') {
this.setState({
paused: true
}, () => {
this.onPause();
});
}
if (event === PLAYBACK_STATUSES.PLAYING) {
this.setState({
paused: false
}, () => {
this.onPlay();
});
}
}
/**
* Handles onReady event.
*
* @returns {void}
*/
_onReady() {
this.setState({
paused: false
});
}
/**
* Retrieves video tag params.
*
* @returns {void}
*/
getPlayerOptions() {
const { _isOwner, videoId, width, height } = this.props;
const options: any = {
height,
initialPlayerParams: {
controls: _isOwner,
modestbranding: true,
preventFullScreen: true
},
play: !this.state.paused,
ref: this.playerRef,
videoId,
volume: 50,
webViewProps: {
bounces: false,
mediaPlaybackRequiresUserAction: false,
scrollEnabled: false,
userAgent: webviewUserAgent
},
width
};
if (_isOwner) {
options.onChangeState = this._onChangeState;
options.onReady = this._onReady;
}
return options;
}
/**
* Implements React Component's render.
*
* @inheritdoc
*/
override render() {
return (
<Video
ref = { this.playerRef }
{ ...this.getPlayerOptions() } />
);
}
}
export default connect(_mapStateToProps)(YoutubeVideoManager);

View File

@@ -0,0 +1,11 @@
/**
* The style of toolbar buttons.
*/
export default {
videoContainer: {
alignItems: 'center',
flex: 1,
flexDirection: 'column',
justifyContent: 'center'
}
};

View File

@@ -0,0 +1,493 @@
// @ts-expect-error
import Logger from '@jitsi/logger';
import { throttle } from 'lodash-es';
import { PureComponent } from 'react';
import { createSharedVideoEvent as createEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState, IStore } from '../../../app/types';
import { getCurrentConference } from '../../../base/conference/functions';
import { IJitsiConference } from '../../../base/conference/reducer';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { getLocalParticipant } from '../../../base/participants/functions';
import { isLocalTrackMuted } from '../../../base/tracks/functions';
import { showWarningNotification } from '../../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../notifications/constants';
import { dockToolbox } from '../../../toolbox/actions';
import { muteLocal } from '../../../video-menu/actions.any';
import { setSharedVideoStatus, stopSharedVideo } from '../../actions';
import { PLAYBACK_STATUSES } from '../../constants';
const logger = Logger.getLogger(__filename);
/**
* Return true if the difference between the two times is larger than 5.
*
* @param {number} newTime - The current time.
* @param {number} previousTime - The previous time.
* @private
* @returns {boolean}
*/
function shouldSeekToPosition(newTime: number, previousTime: number) {
return Math.abs(newTime - previousTime) > 5;
}
/**
* The type of the React {@link PureComponent} props of {@link AbstractVideoManager}.
*/
export interface IProps {
/**
* The current conference.
*/
_conference?: IJitsiConference;
/**
* Warning that indicates an incorrect video url.
*/
_displayWarning: Function;
/**
* Docks the toolbox.
*/
_dockToolbox: Function;
/**
* Indicates whether the local audio is muted.
*/
_isLocalAudioMuted: boolean;
/**
* Is the video shared by the local user.
*
* @private
*/
_isOwner: boolean;
/**
* Mutes local audio track.
*/
_muteLocal: Function;
/**
* Store flag for muted state.
*/
_muted?: boolean;
/**
* The shared video owner id.
*/
_ownerId?: string;
/**
* Updates the shared video status.
*/
_setSharedVideoStatus: Function;
/**
* The shared video status.
*/
_status?: string;
/**
* Action to stop video sharing.
*/
_stopSharedVideo: Function;
/**
* Seek time in seconds.
*
*/
_time?: number;
/**
* The video url.
*/
_videoUrl?: string;
/**
* The video id.
*/
videoId: string;
}
/**
* Manager of shared video.
*/
class AbstractVideoManager extends PureComponent<IProps> {
throttledFireUpdateSharedVideoEvent: Function;
/**
* Initializes a new instance of AbstractVideoManager.
*
* @param {IProps} props - Component props.
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.throttledFireUpdateSharedVideoEvent = throttle(this.fireUpdateSharedVideoEvent.bind(this), 5000);
// selenium tests handler
window._sharedVideoPlayer = this;
}
/**
* Implements React Component's componentDidMount.
*
* @inheritdoc
*/
override componentDidMount() {
this.props._dockToolbox(true);
this.processUpdatedProps();
}
/**
* Implements React Component's componentDidUpdate.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
const { _videoUrl } = this.props;
if (prevProps._videoUrl !== _videoUrl) {
sendAnalytics(createEvent('started'));
}
this.processUpdatedProps();
}
/**
* Implements React Component's componentWillUnmount.
*
* @inheritdoc
*/
override componentWillUnmount() {
sendAnalytics(createEvent('stopped'));
if (this.dispose) {
this.dispose();
}
this.props._dockToolbox(false);
}
/**
* Processes new properties.
*
* @returns {void}
*/
processUpdatedProps() {
const { _status, _time, _isOwner, _muted } = this.props;
if (_isOwner) {
return;
}
const playerTime = this.getTime();
if (shouldSeekToPosition(Number(_time), Number(playerTime))) {
this.seek(Number(_time));
}
if (this.getPlaybackStatus() !== _status) {
if (_status === PLAYBACK_STATUSES.PLAYING) {
this.play();
}
if (_status === PLAYBACK_STATUSES.PAUSED) {
this.pause();
}
}
if (this.isMuted() !== _muted) {
if (_muted) {
this.mute();
} else {
this.unMute();
}
}
}
/**
* Handle video error.
*
* @param {Object|undefined} e - The error returned by the API or none.
* @returns {void}
*/
onError(e?: any) {
logger.error('Error in the video player', e?.data,
e?.data ? 'Check error code at https://developers.google.com/youtube/iframe_api_reference#onError' : '');
this.props._stopSharedVideo();
this.props._displayWarning();
}
/**
* Handle video playing.
*
* @returns {void}
*/
onPlay() {
this.smartAudioMute();
sendAnalytics(createEvent('play'));
this.fireUpdateSharedVideoEvent();
}
/**
* Handle video paused.
*
* @returns {void}
*/
onPause() {
sendAnalytics(createEvent('paused'));
this.fireUpdateSharedVideoEvent();
}
/**
* Handle volume changed.
*
* @returns {void}
*/
onVolumeChange() {
const volume = this.getVolume();
const muted = this.isMuted();
if (Number(volume) > 0 && !muted) {
this.smartAudioMute();
}
sendAnalytics(createEvent(
'volume.changed',
{
volume,
muted
}));
this.fireUpdatePlayingVideoEvent();
}
/**
* Handle changes to the shared playing video.
*
* @returns {void}
*/
fireUpdatePlayingVideoEvent() {
if (this.getPlaybackStatus() === PLAYBACK_STATUSES.PLAYING) {
this.fireUpdateSharedVideoEvent();
}
}
/**
* Dispatches an update action for the shared video.
*
* @returns {void}
*/
fireUpdateSharedVideoEvent() {
const { _isOwner } = this.props;
if (!_isOwner) {
return;
}
const status = this.getPlaybackStatus();
if (!Object.values(PLAYBACK_STATUSES).includes(status ?? '')) {
return;
}
const {
_ownerId,
_setSharedVideoStatus,
_videoUrl
} = this.props;
_setSharedVideoStatus({
videoUrl: _videoUrl,
status,
time: this.getTime(),
ownerId: _ownerId,
muted: this.isMuted()
});
}
/**
* Indicates if the player volume is currently on. This will return true if
* we have an available player, which is currently in a PLAYING state,
* which isn't muted and has it's volume greater than 0.
*
* @returns {boolean} Indicating if the volume of the shared video is
* currently on.
*/
isSharedVideoVolumeOn() {
return this.getPlaybackStatus() === PLAYBACK_STATUSES.PLAYING
&& !this.isMuted()
&& Number(this.getVolume()) > 0;
}
/**
* Smart mike mute. If the mike isn't currently muted and the shared video
* volume is on we mute the mike.
*
* @returns {void}
*/
smartAudioMute() {
const { _isLocalAudioMuted, _muteLocal } = this.props;
if (!_isLocalAudioMuted
&& this.isSharedVideoVolumeOn()) {
sendAnalytics(createEvent('audio.muted'));
_muteLocal(true);
}
}
/**
* Seeks video to provided time.
*
* @param {number} _time - Time to seek to.
* @returns {void}
*/
seek(_time: number) {
// to be implemented by subclass
}
/**
* Indicates the playback state of the video.
*
* @returns {string}
*/
getPlaybackStatus(): string | undefined {
return;
}
/**
* Indicates whether the video is muted.
*
* @returns {boolean}
*/
isMuted(): boolean | undefined {
return;
}
/**
* Retrieves current volume.
*
* @returns {number}
*/
getVolume() {
return 1;
}
/**
* Plays video.
*
* @returns {void}
*/
play() {
// to be implemented by subclass
}
/**
* Pauses video.
*
* @returns {void}
*/
pause() {
// to be implemented by subclass
}
/**
* Mutes video.
*
* @returns {void}
*/
mute() {
// to be implemented by subclass
}
/**
* Unmutes video.
*
* @returns {void}
*/
unMute() {
// to be implemented by subclass
}
/**
* Retrieves current time.
*
* @returns {number}
*/
getTime() {
return 0;
}
/**
* Disposes current video player.
*
* @returns {void}
*/
dispose() {
// to be implemented by subclass
}
}
export default AbstractVideoManager;
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState) {
const { ownerId, status, time, videoUrl, muted } = state['features/shared-video'];
const localParticipant = getLocalParticipant(state);
const _isLocalAudioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
return {
_conference: getCurrentConference(state),
_isLocalAudioMuted,
_isOwner: ownerId === localParticipant?.id,
_muted: muted,
_ownerId: ownerId,
_status: status,
_time: time,
_videoUrl: videoUrl
};
}
/**
* Maps part of the props of this component to Redux actions.
*
* @param {Function} dispatch - The Redux dispatch function.
* @returns {IProps}
*/
export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
_displayWarning: () => {
dispatch(showWarningNotification({
titleKey: 'dialog.shareVideoLinkError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
},
_dockToolbox: (value: boolean) => {
dispatch(dockToolbox(value));
},
_stopSharedVideo: () => {
dispatch(stopSharedVideo());
},
_muteLocal: (value: boolean) => {
dispatch(muteLocal(value, MEDIA_TYPE.AUDIO));
},
_setSharedVideoStatus: ({ videoUrl, status, time, ownerId, muted }: any) => {
dispatch(setSharedVideoStatus({
videoUrl,
status,
time,
ownerId,
muted
}));
}
};
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { DialogProps } from '../../../base/dialog/constants';
import Dialog from '../../../base/ui/components/web/Dialog';
interface IProps extends DialogProps {
/**
* The name of the remote participant that shared the video.
*/
actorName: string;
/**
* The function to execute when confirmed.
*/
onSubmit: () => void;
}
/**
* Dialog to confirm playing a video shared from a remote participant.
*
* @returns {JSX.Element}
*/
export default function ShareVideoConfirmDialog({ actorName, onSubmit }: IProps): JSX.Element {
const { t } = useTranslation();
return (
<Dialog
onSubmit = { onSubmit }
title = { t('dialog.shareVideoConfirmPlayTitle', {
name: actorName
}) }>
<div>
{ t('dialog.shareVideoConfirmPlay') }
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,185 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
// @ts-expect-error
import Filmstrip from '../../../../../modules/UI/videolayout/Filmstrip';
import { IReduxState } from '../../../app/types';
import { FakeParticipant } from '../../../base/participants/types';
import { getVerticalViewMaxWidth } from '../../../filmstrip/functions.web';
import { getLargeVideoParticipant } from '../../../large-video/functions';
import { getToolboxHeight } from '../../../toolbox/functions.web';
import { isSharedVideoEnabled, isVideoPlaying } from '../../functions';
import VideoManager from './VideoManager';
import YoutubeVideoManager from './YoutubeVideoManager';
interface IProps {
/**
* The available client width.
*/
clientHeight: number;
/**
* The available client width.
*/
clientWidth: number;
/**
* Whether the (vertical) filmstrip is visible or not.
*/
filmstripVisible: boolean;
/**
* The width of the vertical filmstrip.
*/
filmstripWidth: number;
/**
* Whether the shared video is enabled or not.
*/
isEnabled: boolean;
/**
* Whether the user is actively resizing the filmstrip.
*/
isResizing: boolean;
/**
* Whether the shared video is currently playing.
*/
isVideoShared: boolean;
/**
* Whether the shared video should be shown on stage.
*/
onStage: boolean;
/**
* The shared video url.
*/
videoUrl?: string;
}
/** .
* 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 SharedVideo extends Component<IProps> {
/**
* Computes the width and the height of the component.
*
* @returns {{
* height: number,
* width: number
* }}
*/
getDimensions() {
const { clientHeight, clientWidth, filmstripVisible, filmstripWidth } = this.props;
let width;
let height;
if (interfaceConfig.VERTICAL_FILMSTRIP) {
if (filmstripVisible) {
width = `${clientWidth - filmstripWidth}px`;
} else {
width = `${clientWidth}px`;
}
height = `${clientHeight - getToolboxHeight()}px`;
} else {
if (filmstripVisible) {
height = `${clientHeight - Filmstrip.getFilmstripHeight()}px`;
} else {
height = `${clientHeight}px`;
}
width = `${clientWidth}px`;
}
return {
width,
height
};
}
/**
* Retrieves the manager to be used for playing the shared video.
*
* @returns {Component}
*/
getManager() {
const { videoUrl } = this.props;
if (!videoUrl) {
return null;
}
if (videoUrl.match(/http/)) {
return <VideoManager videoId = { videoUrl } />;
}
return <YoutubeVideoManager videoId = { videoUrl } />;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {React$Element}
*/
override render() {
const { isEnabled, isResizing, isVideoShared, onStage } = this.props;
if (!isEnabled || !isVideoShared) {
return null;
}
const style: any = this.getDimensions();
if (!onStage) {
style.display = 'none';
}
return (
<div
className = { (isResizing && 'disable-pointer') || '' }
id = 'sharedVideo'
style = { style }>
{this.getManager()}
</div>
);
}
}
/**
* 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 { videoUrl } = state['features/shared-video'];
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const { visible, isResizing } = state['features/filmstrip'];
const { isResizing: isChatResizing } = state['features/chat'];
const onStage = getLargeVideoParticipant(state)?.fakeParticipant === FakeParticipant.SharedVideo;
const isVideoShared = isVideoPlaying(state);
return {
clientHeight,
clientWidth: videoSpaceWidth,
filmstripVisible: visible,
filmstripWidth: getVerticalViewMaxWidth(state),
isEnabled: isSharedVideoEnabled(state),
isResizing: isResizing || isChatResizing,
isVideoShared,
onStage,
videoUrl
};
}
export default connect(_mapStateToProps)(SharedVideo);

View File

@@ -0,0 +1,97 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconPlay } from '../../../base/icons/svg';
import { getLocalParticipant } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { toggleSharedVideo } from '../../actions';
import { isSharingStatus } from '../../functions';
interface IProps extends AbstractButtonProps {
/**
* Whether or not the button is disabled.
*/
_isDisabled: boolean;
/**
* Whether or not the local participant is sharing a video.
*/
_sharingVideo: boolean;
}
/**
* Implements an {@link AbstractButton} to open the user documentation in a new window.
*/
class SharedVideoButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.sharedvideo';
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.stopSharedVideo';
override icon = IconPlay;
override label = 'toolbar.sharedvideo';
override toggledLabel = 'toolbar.stopSharedVideo';
override tooltip = 'toolbar.sharedvideo';
override toggledTooltip = 'toolbar.stopSharedVideo';
/**
* Handles clicking / pressing the button, and opens a new dialog.
*
* @private
* @returns {void}
*/
override _handleClick() {
this._doToggleSharedVideo();
}
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._sharingVideo;
}
/**
* Indicates whether this button is disabled or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isDisabled() {
return this.props._isDisabled;
}
/**
* Dispatches an action to toggle video sharing.
*
* @private
* @returns {void}
*/
_doToggleSharedVideo() {
this.props.dispatch(toggleSharedVideo());
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { ownerId, status: sharedVideoStatus } = state['features/shared-video'];
const localParticipantId = getLocalParticipant(state)?.id;
const isSharing = isSharingStatus(sharedVideoStatus ?? '');
return {
_isDisabled: isSharing && ownerId !== localParticipantId,
_sharingVideo: isSharing
};
}
export default translate(connect(_mapStateToProps)(SharedVideoButton));

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { hideDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import Input from '../../../base/ui/components/web/Input';
import AbstractSharedVideoDialog from '../AbstractSharedVideoDialog';
/**
* Component that renders the video share dialog.
*
* @returns {React$Element<any>}
*/
class SharedVideoDialog extends AbstractSharedVideoDialog<any> {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: any) {
super(props);
this.state = {
value: '',
okDisabled: true,
error: false
};
this._onChange = this._onChange.bind(this);
this._onSubmitValue = this._onSubmitValue.bind(this);
}
/**
* Callback for the onChange event of the field.
*
* @param {string} value - The static event.
* @returns {void}
*/
_onChange(value: string) {
this.setState({
value,
okDisabled: !value
});
}
/**
* Callback to be invoked when the value of the link input is submitted.
*
* @returns {boolean}
*/
_onSubmitValue() {
const result = super._onSetVideoLink(this.state.value);
if (result) {
this.props.dispatch(hideDialog());
} else {
this.setState({
error: true
});
}
return result;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { t } = this.props;
const { error } = this.state;
return (
<Dialog
disableAutoHideOnSubmit = { true }
ok = {{
disabled: this.state.okDisabled,
translationKey: 'dialog.Share'
}}
onSubmit = { this._onSubmitValue }
titleKey = 'dialog.shareVideoTitle'>
<Input
autoFocus = { true }
bottomLabel = { error && t('dialog.sharedVideoDialogError') }
className = 'dialog-bottom-margin'
error = { error }
id = 'shared-video-url-input'
label = { t('dialog.videoLink') }
name = 'sharedVideoUrl'
onChange = { this._onChange }
placeholder = { t('dialog.sharedVideoLinkPlaceholder') }
type = 'text'
value = { this.state.value } />
</Dialog>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
const { allowedUrlDomains } = state['features/shared-video'];
return {
_allowedUrlDomains: allowedUrlDomains
};
}
export default translate(connect(mapStateToProps)(SharedVideoDialog));

View File

@@ -0,0 +1,184 @@
import React from 'react';
import { connect } from 'react-redux';
import { PLAYBACK_STATUSES } from '../../constants';
import AbstractVideoManager, {
IProps,
_mapDispatchToProps,
_mapStateToProps
} from './AbstractVideoManager';
/**
* Manager of shared video.
*/
class VideoManager extends AbstractVideoManager {
playerRef: React.RefObject<HTMLVideoElement>;
/**
* Initializes a new VideoManager instance.
*
* @param {Object} props - This component's props.
*
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.playerRef = React.createRef();
}
/**
* Retrieves the current player ref.
*/
get player() {
return this.playerRef.current;
}
/**
* Indicates the playback state of the video.
*
* @returns {string}
*/
override getPlaybackStatus() {
let status;
if (!this.player) {
return;
}
if (this.player.paused) {
status = PLAYBACK_STATUSES.PAUSED;
} else {
status = PLAYBACK_STATUSES.PLAYING;
}
return status;
}
/**
* Indicates whether the video is muted.
*
* @returns {boolean}
*/
override isMuted() {
return this.player?.muted;
}
/**
* Retrieves current volume.
*
* @returns {number}
*/
override getVolume() {
return Number(this.player?.volume);
}
/**
* Retrieves current time.
*
* @returns {number}
*/
override getTime() {
return Number(this.player?.currentTime);
}
/**
* Seeks video to provided time.
*
* @param {number} time - The time to seek to.
*
* @returns {void}
*/
override seek(time: number) {
if (this.player) {
this.player.currentTime = time;
}
}
/**
* Plays video.
*
* @returns {void}
*/
override play() {
return this.player?.play();
}
/**
* Pauses video.
*
* @returns {void}
*/
override pause() {
return this.player?.pause();
}
/**
* Mutes video.
*
* @returns {void}
*/
override mute() {
if (this.player) {
this.player.muted = true;
}
}
/**
* Unmutes video.
*
* @returns {void}
*/
override unMute() {
if (this.player) {
this.player.muted = false;
}
}
/**
* Retrieves video tag params.
*
* @returns {void}
*/
getPlayerOptions() {
const { _isOwner, videoId } = this.props;
let options: any = {
autoPlay: true,
src: videoId,
controls: _isOwner,
onError: () => this.onError(),
onPlay: () => this.onPlay(),
onVolumeChange: () => this.onVolumeChange()
};
if (_isOwner) {
options = {
...options,
onPause: () => this.onPause(),
onTimeUpdate: this.throttledFireUpdateSharedVideoEvent
};
}
return options;
}
/**
* Implements React Component's render.
*
* @inheritdoc
*/
override render() {
return (
<video
id = 'sharedVideoPlayer'
ref = { this.playerRef }
{ ...this.getPlayerOptions() } />
);
}
}
export default connect(_mapStateToProps, _mapDispatchToProps)(VideoManager);

View File

@@ -0,0 +1,230 @@
/* eslint-disable no-invalid-this */
import React from 'react';
import { connect } from 'react-redux';
import YouTube from 'react-youtube';
import { PLAYBACK_STATUSES } from '../../constants';
import AbstractVideoManager, {
IProps,
_mapDispatchToProps,
_mapStateToProps
} from './AbstractVideoManager';
/**
* Manager of shared video.
*
* @returns {void}
*/
class YoutubeVideoManager extends AbstractVideoManager {
isPlayerAPILoaded: boolean;
player?: any;
/**
* Initializes a new YoutubeVideoManager instance.
*
* @param {Object} props - This component's props.
*
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.isPlayerAPILoaded = false;
}
/**
* Indicates the playback state of the video.
*
* @returns {string}
*/
override getPlaybackStatus() {
let status;
if (!this.player) {
return;
}
const playerState = this.player.getPlayerState();
if (playerState === YouTube.PlayerState.PLAYING) {
status = PLAYBACK_STATUSES.PLAYING;
}
if (playerState === YouTube.PlayerState.PAUSED) {
status = PLAYBACK_STATUSES.PAUSED;
}
return status;
}
/**
* Indicates whether the video is muted.
*
* @returns {boolean}
*/
override isMuted() {
return this.player?.isMuted();
}
/**
* Retrieves current volume.
*
* @returns {number}
*/
override getVolume() {
return this.player?.getVolume();
}
/**
* Retrieves current time.
*
* @returns {number}
*/
override getTime() {
return this.player?.getCurrentTime();
}
/**
* Seeks video to provided time.
*
* @param {number} time - The time to seek to.
*
* @returns {void}
*/
override seek(time: number) {
return this.player?.seekTo(time);
}
/**
* Plays video.
*
* @returns {void}
*/
override play() {
return this.player?.playVideo();
}
/**
* Pauses video.
*
* @returns {void}
*/
override pause() {
return this.player?.pauseVideo();
}
/**
* Mutes video.
*
* @returns {void}
*/
override mute() {
return this.player?.mute();
}
/**
* Unmutes video.
*
* @returns {void}
*/
override unMute() {
return this.player?.unMute();
}
/**
* Disposes of the current video player.
*
* @returns {void}
*/
override dispose() {
if (this.player) {
this.player.destroy();
this.player = null;
}
}
/**
* Fired on play state toggle.
*
* @param {Object} event - The yt player stateChange event.
*
* @returns {void}
*/
onPlayerStateChange = (event: any) => {
if (event.data === YouTube.PlayerState.PLAYING) {
this.onPlay();
} else if (event.data === YouTube.PlayerState.PAUSED) {
this.onPause();
}
};
/**
* Fired when youtube player is ready.
*
* @param {Object} event - The youtube player event.
*
* @returns {void}
*/
onPlayerReady = (event: any) => {
const { _isOwner } = this.props;
this.player = event.target;
this.player.addEventListener('onVolumeChange', () => {
this.onVolumeChange();
});
if (_isOwner) {
this.player.addEventListener('onVideoProgress', this.throttledFireUpdateSharedVideoEvent);
}
this.play();
// sometimes youtube can get muted state from previous videos played in the browser
// and as we are disabling controls we want to unmute it
if (this.isMuted()) {
this.unMute();
}
};
getPlayerOptions = () => {
const { _isOwner, videoId } = this.props;
const showControls = _isOwner ? 1 : 0;
const options = {
id: 'sharedVideoPlayer',
opts: {
height: '100%',
width: '100%',
playerVars: {
'origin': location.origin,
'fs': '0',
'autoplay': 0,
'controls': showControls,
'rel': 0
}
},
onError: (e: any) => this.onError(e),
onReady: this.onPlayerReady,
onStateChange: this.onPlayerStateChange,
videoId
};
return options;
};
/**
* Implements React Component's render.
*
* @inheritdoc
*/
override render() {
return (
<YouTube
{ ...this.getPlayerOptions() } />
);
}
}
export default connect(_mapStateToProps, _mapDispatchToProps)(YoutubeVideoManager);

View File

@@ -0,0 +1,50 @@
/**
* Fixed name of the video player fake participant.
*
* @type {string}
*/
export const VIDEO_PLAYER_PARTICIPANT_NAME = 'Video';
/**
* Fixed name of the youtube player fake participant.
*
* @type {string}
*/
export const YOUTUBE_PLAYER_PARTICIPANT_NAME = 'YouTube';
/**
* Shared video command.
*
* @type {string}
*/
export const SHARED_VIDEO = 'shared-video';
/**
* Available playback statuses.
*/
export const PLAYBACK_STATUSES = {
PLAYING: 'playing',
PAUSED: 'pause',
STOPPED: 'stop'
};
/**
* Playback start state.
*/
export const PLAYBACK_START = 'start';
/**
* The domain for youtube URLs.
*/
export const YOUTUBE_URL_DOMAIN = 'youtube.com';
/**
* The constant to allow URL domains.
*/
export const ALLOW_ALL_URL_DOMAINS = '*';
/**
* The default white listed domains for shared video.
*/
export const DEFAULT_ALLOWED_URL_DOMAINS = [ YOUTUBE_URL_DOMAIN ];

View File

@@ -0,0 +1,166 @@
import { IStateful } from '../base/app/types';
import { IJitsiConference } from '../base/conference/reducer';
import { getFakeParticipants } from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import {
ALLOW_ALL_URL_DOMAINS,
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_VIDEO,
VIDEO_PLAYER_PARTICIPANT_NAME,
YOUTUBE_PLAYER_PARTICIPANT_NAME,
YOUTUBE_URL_DOMAIN
} from './constants';
/**
* Validates the entered video url.
*
* It returns a boolean to reflect whether the url matches the youtube regex.
*
* @param {string} url - The entered video link.
* @returns {string} The youtube video id if matched.
*/
function getYoutubeId(url: string) {
if (!url) {
return null;
}
const p = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|(?:m\.)?youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;// eslint-disable-line max-len
const result = url.match(p);
return result ? result[1] : null;
}
/**
* Checks if the status is one that is actually sharing the video - playing, pause or start.
*
* @param {string} status - The shared video status.
* @returns {boolean}
*/
export function isSharingStatus(status: string) {
return [ PLAYBACK_STATUSES.PLAYING, PLAYBACK_STATUSES.PAUSED, PLAYBACK_START ].includes(status);
}
/**
* Returns true if there is a video being shared in the meeting.
*
* @param {Object | Function} stateful - The Redux state or a function that gets resolved to the Redux state.
* @returns {boolean}
*/
export function isVideoPlaying(stateful: IStateful): boolean {
let videoPlaying = false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [ id, p ] of getFakeParticipants(stateful)) {
if (p.name === VIDEO_PLAYER_PARTICIPANT_NAME || p.name === YOUTUBE_PLAYER_PARTICIPANT_NAME) {
videoPlaying = true;
break;
}
}
return videoPlaying;
}
/**
* Extracts a Youtube id or URL from the user input.
*
* @param {string} input - The user input.
* @returns {string|undefined}
*/
export function extractYoutubeIdOrURL(input: string) {
if (!input) {
return;
}
const trimmedLink = input.trim();
if (!trimmedLink) {
return;
}
const youtubeId = getYoutubeId(trimmedLink);
if (youtubeId) {
return youtubeId;
}
// Check if the URL is valid, native may crash otherwise.
try {
// eslint-disable-next-line no-new
new URL(trimmedLink);
} catch (_) {
return;
}
return trimmedLink;
}
/**
* Returns true if shared video functionality is enabled and false otherwise.
*
* @param {IStateful} stateful - - The redux store or {@code getState} function.
* @returns {boolean}
*/
export function isSharedVideoEnabled(stateful: IStateful) {
const state = toState(stateful);
const { disableThirdPartyRequests = false } = state['features/base/config'];
return !disableThirdPartyRequests;
}
/**
* Returns true if the passed url is allowed to be used for shared video or not.
*
* @param {string} url - The URL.
* @param {Array<string>} allowedUrlDomains - The allowed url domains.
* @param {boolean} considerNonURLsAllowedForYoututbe - If true, the invalid URLs will be considered youtube IDs
* and if youtube is allowed the function will return true.
* @returns {boolean}
*/
export function isURLAllowedForSharedVideo(url: string,
allowedUrlDomains: Array<string> = [], considerNonURLsAllowedForYoututbe = false) {
if (!url) {
return false;
}
try {
const urlObject = new URL(url);
if ([ 'http:', 'https:' ].includes(urlObject?.protocol?.toLowerCase())) {
return allowedUrlDomains.includes(ALLOW_ALL_URL_DOMAINS) || allowedUrlDomains.includes(urlObject?.hostname);
}
} catch (_e) { // it should be YouTube id.
return considerNonURLsAllowedForYoututbe && allowedUrlDomains.includes(YOUTUBE_URL_DOMAIN);
}
return false;
}
/**
* Sends SHARED_VIDEO command.
*
* @param {string} id - The id of the video.
* @param {string} status - The status of the shared video.
* @param {JitsiConference} conference - The current conference.
* @param {string} localParticipantId - The id of the local participant.
* @param {string} time - The seek position of the video.
* @returns {void}
*/
export function sendShareVideoCommand({ id, status, conference, localParticipantId = '', time, muted, volume }: {
conference?: IJitsiConference; id: string; localParticipantId?: string; muted?: boolean;
status: string; time: number; volume?: number;
}) {
conference?.sendCommandOnce(SHARED_VIDEO, {
value: id,
attributes: {
from: localParticipantId,
muted,
state: status,
time,
volume
}
});
}

View File

@@ -0,0 +1,24 @@
import { useSelector } from 'react-redux';
import { SharedVideoButton } from './components';
import { isSharedVideoEnabled } from './functions';
const shareVideo = {
key: 'sharedvideo',
Content: SharedVideoButton,
group: 3
};
/**
* A hook that returns the shared video button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined}
*/
export function useSharedVideoButton() {
const sharedVideoEnabled = useSelector(isSharedVideoEnabled);
if (sharedVideoEnabled) {
return shareVideo;
}
}

View File

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

View File

@@ -0,0 +1,290 @@
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { CONFERENCE_JOIN_IN_PROGRESS, CONFERENCE_LEFT } from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import { IJitsiConference } from '../base/conference/reducer';
import { SET_CONFIG } from '../base/config/actionTypes';
import { MEDIA_TYPE } from '../base/media/constants';
import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
import { participantJoined, participantLeft, pinParticipant } from '../base/participants/actions';
import { getLocalParticipant, getParticipantById, getParticipantDisplayName } from '../base/participants/functions';
import { FakeParticipant } from '../base/participants/types';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { SET_DYNAMIC_BRANDING_DATA } from '../dynamic-branding/actionTypes';
import { showWarningNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { RESET_SHARED_VIDEO_STATUS, SET_SHARED_VIDEO_STATUS } from './actionTypes';
import {
hideConfirmPlayingDialog,
resetSharedVideoStatus,
setAllowedUrlDomians,
setSharedVideoStatus,
showConfirmPlayingDialog
} from './actions';
import {
DEFAULT_ALLOWED_URL_DOMAINS,
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_VIDEO,
VIDEO_PLAYER_PARTICIPANT_NAME
} from './constants';
import { isSharedVideoEnabled, isSharingStatus, isURLAllowedForSharedVideo, sendShareVideoCommand } from './functions';
import logger from './logger';
/**
* Middleware that captures actions related to video sharing and updates
* components not hooked into redux.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const { dispatch, getState } = store;
if (!isSharedVideoEnabled(getState())) {
return next(action);
}
switch (action.type) {
case CONFERENCE_JOIN_IN_PROGRESS: {
const { conference } = action;
const localParticipantId = getLocalParticipant(getState())?.id;
conference.addCommandListener(SHARED_VIDEO,
({ value, attributes }: { attributes: {
muted: string; state: string; time: string; }; value: string; },
from: string) => {
const state = getState();
const sharedVideoStatus = attributes.state;
const { ownerId } = state['features/shared-video'];
if (ownerId && ownerId !== from) {
logger.warn(
`User with id: ${from} sent shared video command: ${sharedVideoStatus} while we are playing.`);
return;
}
if (isSharingStatus(sharedVideoStatus)) {
// confirmShowVideo is undefined the first time we receive
// when confirmShowVideo is false we ignore everything except stop that resets it
if (state['features/shared-video'].confirmShowVideo === false) {
return;
}
if (isURLAllowedForSharedVideo(value, state['features/shared-video'].allowedUrlDomains, true)
|| localParticipantId === from
|| state['features/shared-video'].confirmShowVideo) { // if confirmed skip asking again
handleSharingVideoStatus(store, value, {
...attributes,
from
}, conference);
} else {
dispatch(showConfirmPlayingDialog(getParticipantDisplayName(state, from), () => {
handleSharingVideoStatus(store, value, {
...attributes,
from
}, conference);
return true; // on mobile this is used to close the dialog
}));
}
return;
}
if (sharedVideoStatus === 'stop') {
const videoParticipant = getParticipantById(state, value);
if (state['features/shared-video'].confirmShowVideo === false) {
dispatch(showWarningNotification({
titleKey: 'dialog.shareVideoLinkStopped',
titleArguments: {
name: getParticipantDisplayName(state, from)
}
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
}
dispatch(hideConfirmPlayingDialog());
dispatch(participantLeft(value, conference, {
fakeParticipant: videoParticipant?.fakeParticipant
}));
if (localParticipantId !== from) {
dispatch(resetSharedVideoStatus());
}
}
}
);
break;
}
case CONFERENCE_LEFT:
dispatch(setAllowedUrlDomians(DEFAULT_ALLOWED_URL_DOMAINS));
dispatch(resetSharedVideoStatus());
break;
case PARTICIPANT_LEFT: {
const state = getState();
const conference = getCurrentConference(state);
const { ownerId: stateOwnerId, videoUrl: statevideoUrl } = state['features/shared-video'];
if (action.participant.id === stateOwnerId) {
batch(() => {
dispatch(resetSharedVideoStatus());
dispatch(participantLeft(statevideoUrl ?? '', conference));
});
}
break;
}
case SET_CONFIG:
case SET_DYNAMIC_BRANDING_DATA: {
const result = next(action);
const state = getState();
const { sharedVideoAllowedURLDomains: allowedURLDomainsFromConfig = [] } = state['features/base/config'];
const { sharedVideoAllowedURLDomains: allowedURLDomainsFromBranding = [] } = state['features/dynamic-branding'];
dispatch(setAllowedUrlDomians([
...DEFAULT_ALLOWED_URL_DOMAINS,
...allowedURLDomainsFromBranding,
...allowedURLDomainsFromConfig
]));
return result;
}
case SET_SHARED_VIDEO_STATUS: {
const state = getState();
const conference = getCurrentConference(state);
const localParticipantId = getLocalParticipant(state)?.id;
const { videoUrl, status, ownerId, time, muted, volume } = action;
const operator = status === PLAYBACK_STATUSES.PLAYING ? 'is' : '';
logger.debug(`User with id: ${ownerId} ${operator} ${status} video sharing.`);
if (typeof APP !== 'undefined') {
APP.API.notifyAudioOrVideoSharingToggled(MEDIA_TYPE.VIDEO, status, ownerId);
}
// when setting status we need to send the command for that, but not do it for the start command
// as we are sending the command in playSharedVideo and setting the start status once
// we receive the response, this way we will start the video at the same time when remote participants
// start it, on receiving the command
if (status === 'start') {
break;
}
if (localParticipantId === ownerId) {
sendShareVideoCommand({
conference,
localParticipantId,
muted,
status,
time,
id: videoUrl,
volume
});
}
break;
}
case RESET_SHARED_VIDEO_STATUS: {
const state = getState();
const localParticipantId = getLocalParticipant(state)?.id;
const { ownerId: stateOwnerId, videoUrl: statevideoUrl } = state['features/shared-video'];
if (!stateOwnerId) {
break;
}
logger.debug(`User with id: ${stateOwnerId} stop video sharing.`);
if (typeof APP !== 'undefined') {
APP.API.notifyAudioOrVideoSharingToggled(MEDIA_TYPE.VIDEO, 'stop', stateOwnerId);
}
if (localParticipantId === stateOwnerId) {
const conference = getCurrentConference(state);
sendShareVideoCommand({
conference,
id: statevideoUrl ?? '',
localParticipantId,
muted: true,
status: 'stop',
time: 0,
volume: 0
});
}
break;
}
}
return next(action);
});
/**
* Handles the playing, pause and start statuses for the shared video.
* Dispatches participantJoined event and, if necessary, pins it.
* Sets the SharedVideoStatus if the event was triggered by the local user.
*
* @param {Store} store - The redux store.
* @param {string} videoUrl - The id of the video to the shared.
* @param {Object} attributes - The attributes received from the share video command.
* @param {JitsiConference} conference - The current conference.
* @returns {void}
*/
function handleSharingVideoStatus(store: IStore, videoUrl: string,
{ state, time, from, muted }: { from: string; muted: string; state: string; time: string; },
conference: IJitsiConference) {
const { dispatch, getState } = store;
const localParticipantId = getLocalParticipant(getState())?.id;
const oldStatus = getState()['features/shared-video']?.status ?? '';
const oldVideoUrl = getState()['features/shared-video'].videoUrl;
if (oldVideoUrl && oldVideoUrl !== videoUrl) {
logger.warn(
`User with id: ${from} sent videoUrl: ${videoUrl} while we are playing: ${oldVideoUrl}`);
return;
}
// If the video was not started (no participant) we want to create the participant
// this can be triggered by start, but also by paused or playing
// commands (joining late) and getting the current state
if (state === PLAYBACK_START || !isSharingStatus(oldStatus)) {
const youtubeId = videoUrl.match(/http/) ? false : videoUrl;
const avatarURL = youtubeId ? `https://img.youtube.com/vi/${youtubeId}/0.jpg` : '';
dispatch(participantJoined({
conference,
fakeParticipant: FakeParticipant.SharedVideo,
id: videoUrl,
avatarURL,
name: VIDEO_PLAYER_PARTICIPANT_NAME
}));
dispatch(pinParticipant(videoUrl));
if (localParticipantId === from) {
dispatch(setSharedVideoStatus({
videoUrl,
status: state,
time: Number(time),
ownerId: localParticipantId
}));
}
}
if (localParticipantId !== from) {
dispatch(setSharedVideoStatus({
muted: muted === 'true',
ownerId: from,
status: state,
time: Number(time),
videoUrl
}));
}
}

View File

@@ -0,0 +1,66 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
RESET_SHARED_VIDEO_STATUS,
SET_ALLOWED_URL_DOMAINS,
SET_CONFIRM_SHOW_VIDEO,
SET_SHARED_VIDEO_STATUS
} from './actionTypes';
import { DEFAULT_ALLOWED_URL_DOMAINS } from './constants';
const initialState = {
allowedUrlDomains: DEFAULT_ALLOWED_URL_DOMAINS
};
export interface ISharedVideoState {
allowedUrlDomains: Array<string>;
confirmShowVideo?: boolean;
muted?: boolean;
ownerId?: string;
status?: string;
time?: number;
videoUrl?: string;
volume?: number;
}
/**
* Reduces the Redux actions of the feature features/shared-video.
*/
ReducerRegistry.register<ISharedVideoState>('features/shared-video',
(state = initialState, action): ISharedVideoState => {
const { videoUrl, status, time, ownerId, muted, volume } = action;
switch (action.type) {
case RESET_SHARED_VIDEO_STATUS:
return {
...initialState,
allowedUrlDomains: state.allowedUrlDomains
};
case SET_CONFIRM_SHOW_VIDEO: {
return {
...state,
confirmShowVideo: action.value
};
}
case SET_SHARED_VIDEO_STATUS:
return {
...state,
muted,
ownerId,
status,
time,
videoUrl,
volume
};
case SET_ALLOWED_URL_DOMAINS: {
return {
...state,
allowedUrlDomains: action.allowedUrlDomains
};
}
default:
return state;
}
});