diff --git a/conference.js b/conference.js index 36c875836268..5e1dcd52c4f1 100644 --- a/conference.js +++ b/conference.js @@ -2517,6 +2517,8 @@ export default { if (state === 'stop' || state === 'start' || state === 'playing') { + const localParticipant = getLocalParticipant(APP.store.getState()); + room.removeCommand(this.commands.defaults.SHARED_VIDEO); room.sendCommandOnce(this.commands.defaults.SHARED_VIDEO, { value: url, @@ -2524,7 +2526,8 @@ export default { state, time, muted: isMuted, - volume + volume, + from: localParticipant.id } }); } else { diff --git a/package-lock.json b/package-lock.json index d3ee831b349e..faeb00f23cdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8057,8 +8057,7 @@ "events": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz", - "integrity": "sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg==", - "dev": true + "integrity": "sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg==" }, "eventsource": { "version": "1.0.7", @@ -15104,6 +15103,14 @@ } } }, + "react-native-youtube-iframe": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/react-native-youtube-iframe/-/react-native-youtube-iframe-1.2.3.tgz", + "integrity": "sha512-3O8OFJyohGNlYX4D97aWfLLlhEHhlLHDCLgXM+SsQBwP9r1oLnKgXWoy1gce+Vr8qgrqeQgmx1ki+10AAd4KWQ==", + "requires": { + "events": "^3.0.0" + } + }, "react-node-resolver": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/react-node-resolver/-/react-node-resolver-1.0.1.tgz", diff --git a/package.json b/package.json index 3f069fd35a07..7077248454ac 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "react-native-watch-connectivity": "0.4.3", "react-native-webrtc": "1.75.3", "react-native-webview": "7.4.1", + "react-native-youtube-iframe": "1.2.3", "react-redux": "7.1.0", "react-textarea-autosize": "7.1.0", "react-transition-group": "2.4.0", diff --git a/react/features/base/participants/components/ParticipantView.native.js b/react/features/base/participants/components/ParticipantView.native.js index d9911319f3da..0c17a3dc0336 100644 --- a/react/features/base/participants/components/ParticipantView.native.js +++ b/react/features/base/participants/components/ParticipantView.native.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { Text, View } from 'react-native'; +import { YoutubeLargeVideo } from '../../../youtube-player'; import { Avatar } from '../../avatar'; import { translate } from '../../i18n'; import { JitsiParticipantConnectionStatus } from '../../lib-jitsi-meet'; @@ -15,7 +16,7 @@ import { connect } from '../../redux'; import type { StyleType } from '../../styles'; import { TestHint } from '../../testing/components'; import { getTrackByMediaTypeAndParticipant } from '../../tracks'; -import { shouldRenderParticipantVideo } from '../functions'; +import { shouldRenderParticipantVideo, getParticipantById } from '../functions'; import styles from './styles'; @@ -33,6 +34,13 @@ type Props = { */ _connectionStatus: string, + /** + * True if the participant which this component represents is fake. + * + * @private + */ + _isFakeParticipant: boolean, + /** * The name of the participant which this component represents. * @@ -181,8 +189,10 @@ class ParticipantView extends Component { render() { const { _connectionStatus: connectionStatus, + _isFakeParticipant, _renderVideo: renderVideo, _videoTrack: videoTrack, + disableVideo, onPress, tintStyle } = this.props; @@ -198,9 +208,11 @@ class ParticipantView extends Component { ? this.props.testHintId : `org.jitsi.meet.Participant#${this.props.participantId}`; + const renderYoutubeLargeVideo = _isFakeParticipant && !disableVideo; + return ( { - { renderVideo + { renderYoutubeLargeVideo && } + + { !_isFakeParticipant && renderVideo && { zOrder = { this.props.zOrder } zoomEnabled = { this.props.zoomEnabled } /> } - { !renderVideo + { !renderYoutubeLargeVideo && !renderVideo && { */ function _mapStateToProps(state, ownProps) { const { disableVideo, participantId } = ownProps; + const participant = getParticipantById(state, participantId); let connectionStatus; let participantName; @@ -260,6 +275,7 @@ function _mapStateToProps(state, ownProps) { _connectionStatus: connectionStatus || JitsiParticipantConnectionStatus.ACTIVE, + _isFakeParticipant: participant && participant.isFakeParticipant, _participantName: participantName, _renderVideo: shouldRenderParticipantVideo(state, participantId) && !disableVideo, _videoTrack: diff --git a/react/features/base/participants/functions.js b/react/features/base/participants/functions.js index 69e2c952e2b5..c554e72288ad 100644 --- a/react/features/base/participants/functions.js +++ b/react/features/base/participants/functions.js @@ -241,6 +241,23 @@ function _getAllParticipants(stateful) { : toState(stateful)['features/base/participants'] || []); } +/** + * Returns the youtube fake participant. + * At the moment it is considered the youtube participant the only fake participant in the list. + * + * @param {(Function|Object|Participant[])} stateful - The redux state + * features/base/participants, the (whole) redux state, or redux's + * {@code getState} function to be used to retrieve the state + * features/base/participants. + * @private + * @returns {Participant} + */ +export function getYoutubeParticipant(stateful: Object | Function) { + const participants = _getAllParticipants(stateful); + + return participants.filter(p => p.isFakeParticipant)[0]; +} + /** * Returns true if all of the meeting participants are moderators. * diff --git a/react/features/display-name/components/native/DisplayNameLabel.js b/react/features/display-name/components/native/DisplayNameLabel.js index a875d789884d..04e2747635df 100644 --- a/react/features/display-name/components/native/DisplayNameLabel.js +++ b/react/features/display-name/components/native/DisplayNameLabel.js @@ -5,6 +5,7 @@ import { Text, View } from 'react-native'; import { getLocalParticipant, + getParticipantById, getParticipantDisplayName, shouldRenderParticipantVideo } from '../../../base/participants'; @@ -65,13 +66,16 @@ class DisplayNameLabel extends Component { function _mapStateToProps(state: Object, ownProps: Props) { const { participantId } = ownProps; const localParticipant = getLocalParticipant(state); + const participant = getParticipantById(state, participantId); + const isFakeParticipant = participant && participant.isFakeParticipant; // Currently we only render the display name if it's not the local // participant and there is no video rendered for // them. const _render = Boolean(participantId) && localParticipant.id !== participantId - && !shouldRenderParticipantVideo(state, participantId); + && !shouldRenderParticipantVideo(state, participantId) + && !isFakeParticipant; return { _participantName: diff --git a/react/features/filmstrip/components/native/Thumbnail.js b/react/features/filmstrip/components/native/Thumbnail.js index 04bbb36b108d..03e29b70415c 100644 --- a/react/features/filmstrip/components/native/Thumbnail.js +++ b/react/features/filmstrip/components/native/Thumbnail.js @@ -150,7 +150,7 @@ function Thumbnail(props: Props) { - } + } - { renderDominantSpeakerIndicator && } - + } - - + } - + { !participant.isFakeParticipant && { audioMuted && } { videoMuted && } - + } ); diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 406eb6e5eed8..1167a586d517 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -17,6 +17,7 @@ import { LiveStreamButton, RecordButton } from '../../../recording'; import { RoomLockButton } from '../../../room-lock'; import { ClosedCaptionButton } from '../../../subtitles'; import { TileViewButton } from '../../../video-layout'; +import { VideoShareButton } from '../../../youtube-player'; import HelpButton from '../HelpButton'; import AudioOnlyButton from './AudioOnlyButton'; @@ -136,6 +137,7 @@ class OverflowMenu extends PureComponent { + diff --git a/react/features/youtube-player/actionTypes.js b/react/features/youtube-player/actionTypes.js new file mode 100644 index 000000000000..c908da8358cd --- /dev/null +++ b/react/features/youtube-player/actionTypes.js @@ -0,0 +1,22 @@ +/** + * The type of the action which signals to update the current known state of the + * shared YouTube video. + * + * { + * type: SET_SHARED_VIDEO_STATUS, + * status: string, + * time: string, + * ownerId: string + * } + */ +export const SET_SHARED_VIDEO_STATUS = 'SET_SHARED_VIDEO_STATUS'; + +/** + * The type of the action which signals to start the flow for starting or + * stopping a shared YouTube video. + * + * { + * type: TOGGLE_SHARED_VIDEO + * } + */ +export const TOGGLE_SHARED_VIDEO = 'TOGGLE_SHARED_VIDEO'; diff --git a/react/features/youtube-player/actions.js b/react/features/youtube-player/actions.js new file mode 100644 index 000000000000..f56b23dc7129 --- /dev/null +++ b/react/features/youtube-player/actions.js @@ -0,0 +1,54 @@ +// @flow + +import { openDialog } from '../base/dialog'; + +import { SET_SHARED_VIDEO_STATUS } from './actionTypes'; +import { EnterVideoLinkPrompt } from './components'; + +/** + * Updates the current known status of the shared YouTube video. + * + * @param {string} videoId - The youtubeId of the video to be shared. + * @param {string} status - The current status of the YouTube video being shared. + * @param {number} time - The current position of the YouTube video being shared. + * @param {string} ownerId - The participantId of the user sharing the YouTube video. + * @returns {{ + * type: SET_SHARED_VIDEO_STATUS, + * ownerId: string, + * status: string, + * time: number, + * videoId: string + * }} + */ +export function setSharedVideoStatus(videoId: string, status: string, time: number, ownerId: string) { + return { + type: SET_SHARED_VIDEO_STATUS, + ownerId, + status, + time, + videoId + }; +} + +/** + * Starts the flow for starting or stopping a shared YouTube video. + * + * @returns {{ + * type: TOGGLE_SHARED_VIDEO + * }} + */ +export function toggleSharedVideo() { + return { + type: 'TOGGLE_SHARED_VIDEO' + }; +} + +/** + * Displays the prompt for entering the youtube video link. + * + * @param {Function} onPostSubmit - The function to be invoked when a valid link is entered. + * @returns {Function} + */ +export function showEnterVideoLinkPrompt(onPostSubmit: ?Function) { + return openDialog(EnterVideoLinkPrompt, { onPostSubmit }); +} diff --git a/react/features/youtube-player/components/AbstractEnterVideoLinkPrompt.js b/react/features/youtube-player/components/AbstractEnterVideoLinkPrompt.js new file mode 100644 index 000000000000..97a8f34e2124 --- /dev/null +++ b/react/features/youtube-player/components/AbstractEnterVideoLinkPrompt.js @@ -0,0 +1,83 @@ +// @flow + +import { Component } from 'react'; +import type { Dispatch } from 'redux'; + +/** + * The type of the React {@code Component} props of + * {@link AbstractEnterVideoLinkPrompt}. + */ +export type Props = { + + /** + * Invoked to update the shared youtube video link. + */ + dispatch: Dispatch, + + /** + * Function to be invoked after typing a valid youtube video . + */ + onPostSubmit: ?Function +}; + +/** + * Implements an abstract class for {@code EnterVideoLinkPrompt}. + */ +export default class AbstractEnterVideoLinkPrompt extends Component < Props, S > { + /** + * Instantiates a new component. + * + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onSetVideoLink = this._onSetVideoLink.bind(this); + } + + _onSetVideoLink: string => boolean; + + /** + * Validates the entered video link by extractibg 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) { + if (!link || !link.trim()) { + return false; + } + + const videoId = getYoutubeLink(link); + + if (videoId) { + const { onPostSubmit } = this.props; + + onPostSubmit && onPostSubmit(videoId); + + return true; + } + + return false; + } +} + +/** + * 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 {boolean} + */ +function getYoutubeLink(url) { + const p = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|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] : false; +} diff --git a/react/features/youtube-player/components/VideoShareButton.js b/react/features/youtube-player/components/VideoShareButton.js new file mode 100644 index 000000000000..a39f0ca5ba0e --- /dev/null +++ b/react/features/youtube-player/components/VideoShareButton.js @@ -0,0 +1,122 @@ +// @flow + +import type { Dispatch } from 'redux'; + +import { translate } from '../../base/i18n'; +import { IconShareVideo } from '../../base/icons'; +import { getLocalParticipant } from '../../base/participants'; +import { connect } from '../../base/redux'; +import { AbstractButton } from '../../base/toolbox'; +import type { AbstractButtonProps } from '../../base/toolbox'; +import { toggleSharedVideo } from '../actions'; + +/** + * The type of the React {@code Component} props of {@link TileViewButton}. + */ +type Props = AbstractButtonProps & { + + /** + * Whether or not the button is disabled. + */ + _isDisabled: boolean, + + /** + * Whether or not the local participant is sharing a YouTube video. + */ + _sharingVideo: boolean, + + /** + * The redux {@code dispatch} function. + */ + dispatch: Dispatch +}; + +/** + * Component that renders a toolbar button for toggling the tile layout view. + * + * @extends AbstractButton + */ +class VideoShareButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.sharedvideo'; + icon = IconShareVideo; + label = 'toolbar.sharedvideo'; + toggledLabel = 'toolbar.stopSharedVideo'; + + /** + * Handles clicking / pressing the button. + * + * @override + * @protected + * @returns {void} + */ + _handleClick() { + this._doToggleSharedVideo(); + } + + /** + * Indicates whether this button is in toggled state or not. + * + * @override + * @protected + * @returns {boolean} + */ + _isToggled() { + return this.props._sharingVideo; + } + + /** + * Indicates whether this button is disabled or not. + * + * @override + * @protected + * @returns {boolean} + */ + _isDisabled() { + return this.props._isDisabled; + } + + /** + * Dispatches an action to toggle YouTube 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 {Props} + */ +function _mapStateToProps(state): Object { + const { ownerId, status: sharedVideoStatus } = state['features/youtube-player']; + const localParticipantId = getLocalParticipant(state).id; + + if (ownerId !== localParticipantId) { + return { + _isDisabled: isSharingStatus(sharedVideoStatus), + _sharingVideo: false }; + } + + return { + _sharingVideo: isSharingStatus(sharedVideoStatus) + }; +} + +/** + * Checks if the status is one that is actually sharing the video - playing, pause or start. + * + * @param {string} status - The shared video status. + * @private + * @returns {boolean} + */ +function isSharingStatus(status) { + return [ 'playing', 'pause', 'start' ].includes(status); +} + +export default translate(connect(_mapStateToProps)(VideoShareButton)); diff --git a/react/features/youtube-player/components/_.native.js b/react/features/youtube-player/components/_.native.js new file mode 100644 index 000000000000..738c4d2b8a08 --- /dev/null +++ b/react/features/youtube-player/components/_.native.js @@ -0,0 +1 @@ +export * from './native'; diff --git a/react/features/youtube-player/components/index.js b/react/features/youtube-player/components/index.js new file mode 100644 index 000000000000..13b9fa209444 --- /dev/null +++ b/react/features/youtube-player/components/index.js @@ -0,0 +1,3 @@ +export { default as VideoShareButton } from './VideoShareButton'; + +export * from './_'; diff --git a/react/features/youtube-player/components/native/EnterVideoLinkPrompt.js b/react/features/youtube-player/components/native/EnterVideoLinkPrompt.js new file mode 100644 index 000000000000..e0aefdbc9682 --- /dev/null +++ b/react/features/youtube-player/components/native/EnterVideoLinkPrompt.js @@ -0,0 +1,32 @@ +// @flow + +import React from 'react'; + +import { InputDialog } from '../../../base/dialog'; +import { connect } from '../../../base/redux'; +import AbstractEnterVideoLinkPrompt from '../AbstractEnterVideoLinkPrompt'; + +/** + * Implements a component to render a display name prompt. + */ +class EnterVideoLinkPrompt extends AbstractEnterVideoLinkPrompt<*> { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + */ + render() { + return ( + + ); + } + + _onSetVideoLink: string => boolean; +} + +export default connect()(EnterVideoLinkPrompt); diff --git a/react/features/youtube-player/components/native/YoutubeLargeVideo.js b/react/features/youtube-player/components/native/YoutubeLargeVideo.js new file mode 100644 index 000000000000..1229d5b378df --- /dev/null +++ b/react/features/youtube-player/components/native/YoutubeLargeVideo.js @@ -0,0 +1,311 @@ +// @flow + +import React, { useRef, useEffect } from 'react'; +import { View } from 'react-native'; +import YoutubePlayer from 'react-native-youtube-iframe'; + +import { getLocalParticipant } from '../../../base/participants'; +import { connect } from '../../../base/redux'; +import { ASPECT_RATIO_WIDE } from '../../../base/responsive-ui/constants'; +import { setToolboxVisible } from '../../../toolbox/actions'; +import { setSharedVideoStatus } from '../../actions'; + +import styles from './styles'; + +/** + * 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 + +/** + * The type of the React {@link Component} props of {@link YoutubeLargeVideo}. + */ +type Props = { + + /** + * Display the youtube controls on the player. + * + * @private + */ + _enableControls: boolean, + + /** + * Is the video shared by the local user. + * + * @private + */ + _isOwner: boolean, + + /** + * The ID of the participant (to be) depicted by LargeVideo. + * + * @private + */ + _isPlaying: string, + + /** + * True if in landscape mode. + * + * @private + */ + _isWideScreen: boolean, + + /** + * Callback to invoke when the {@code YoutLargeVideo} is ready to play. + * + * @private + */ + _onVideoReady: Function, + + /** + * Callback to invoke when the {@code YoutubeLargeVideo} changes status. + * + * @private + */ + _onVideoChangeEvent: Function, + + /** + * Callback to invoke when { @code isWideScreen} changes. + * + * @private + */ + _onWideScreenChanged: Function, + + /** + * The id of the participant sharing the video. + * + * @private + */ + _ownerId: string, + + /** + * The height of the screen. + * + * @private + */ + _screenHeight: number, + + /** + * The width of the screen. + * + * @private + */ + _screenWidth: number, + + /** + * Seek time in seconds. + * + * @private + */ + _seek: number, + + /** + * Youtube id of the video to be played. + * + * @private + */ + youtubeId: string +}; + +const YoutubeLargeVideo = (props: Props) => { + const playerRef = useRef(null); + + useEffect(() => { + playerRef.current && playerRef.current.getCurrentTime().then(time => { + const { _seek } = props; + + if (shouldSeekToPosition(_seek, time)) { + playerRef.current && playerRef.current.seekTo(_seek); + } + }); + }, [ props._seek ]); + + useEffect(() => { + props._onWideScreenChanged(props._isWideScreen); + }, [ props._isWideScreen ]); + + const onChangeState = e => + playerRef.current && playerRef.current.getCurrentTime().then(time => { + const { + _isOwner, + _isPlaying, + _seek + } = props; + + if (shouldSetNewStatus(_isOwner, e, _isPlaying, time, _seek)) { + props._onVideoChangeEvent(props.youtubeId, e, time, props._ownerId); + } + }); + const onReady = () => { + if (props._isOwner) { + props._onVideoReady( + props.youtubeId, + playerRef.current && playerRef.current.getCurrentTime(), + props._ownerId); + } + }; + + let playerHeight, playerWidth; + + if (props._isWideScreen) { + playerHeight = props._screenHeight; + playerWidth = playerHeight * 16 / 9; + } else { + playerWidth = props._screenWidth; + playerHeight = playerWidth * 9 / 16; + } + + return ( + + + ); +}; + +/* eslint-disable max-params */ + +/** + * Return true if the user is the owner and + * the status has changed or the seek time difference from the previous set is larger than 5 seconds. + * + * @param {boolean} isOwner - Whether the local user is sharing the video. + * @param {string} status - The new status. + * @param {boolean} isPlaying - Whether the component is playing at the moment. + * @param {number} newTime - The new seek time. + * @param {number} previousTime - The old seek time. + * @private + * @returns {boolean} +*/ +function shouldSetNewStatus(isOwner, status, isPlaying, newTime, previousTime) { + if (!isOwner || status === 'buffering') { + return false; + } + + if ((isPlaying && status === 'paused') || (!isPlaying && status === 'playing')) { + return true; + } + + return shouldSeekToPosition(newTime, previousTime); +} + +/** + * Return true if the diffenrece between the two timees is larger than 5. + * + * @param {number} newTime - The current time. + * @param {number} previousTime - The previous time. + * @private + * @returns {boolean} +*/ +function shouldSeekToPosition(newTime, previousTime) { + return Math.abs(newTime - previousTime) > 5; +} + +/** + * Maps (parts of) the Redux state to the associated YoutubeLargeVideo's props. + * + * @param {Object} state - Redux state. + * @private + * @returns {Props} + */ +function _mapStateToProps(state) { + const { ownerId, status, time } = state['features/youtube-player']; + const localParticipant = getLocalParticipant(state); + const responsiveUi = state['features/base/responsive-ui']; + const screenHeight = responsiveUi.clientHeight; + const screenWidth = responsiveUi.clientWidth; + + return { + _enableControls: ownerId === localParticipant.id, + _isOwner: ownerId === localParticipant.id, + _isPlaying: status === 'playing', + _isWideScreen: responsiveUi.aspectRatio === ASPECT_RATIO_WIDE, + _ownerId: ownerId, + _screenHeight: screenHeight, + _screenWidth: screenWidth, + _seek: time + }; +} + +/** + * Maps dispatching of some action to React component props. + * + * @param {Function} dispatch - Redux action dispatcher. + * @private + * @returns {{ + * onVideoChangeEvent: Function, + * onVideoReady: Function, + * onWideScreenChanged: Function + * }} + */ +function _mapDispatchToProps(dispatch) { + return { + _onVideoChangeEvent: (videoId, status, time, ownerId) => { + if (![ 'playing', 'paused' ].includes(status)) { + return; + } + dispatch(setSharedVideoStatus(videoId, translateStatus(status), time, ownerId)); + }, + _onVideoReady: (videoId, time, ownerId) => { + time.then(t => dispatch(setSharedVideoStatus(videoId, 'playing', t, ownerId))); + }, + _onWideScreenChanged: isWideScreen => { + dispatch(setToolboxVisible(!isWideScreen)); + } + }; +} + +/** + * Maps (parts of) the Redux state to the associated YoutubeLargeVideo's props. + * + * @private + * @returns {Props} + */ +function _mergeProps({ _isOwner, ...stateProps }, { _onVideoChangeEvent, _onVideoReady, _onWideScreenChanged }) { + return Object.assign(stateProps, { + _onVideoChangeEvent: _isOwner ? _onVideoChangeEvent : () => { /* do nothing */ }, + _onVideoReady: _isOwner ? _onVideoReady : () => { /* do nothing */ }, + _onWideScreenChanged + }); +} + +/** + * In case the status is 'paused', it is translated to 'pause' to match the web functionality. + * + * @param {string} status - The status of the shared video. + * @private + * @returns {string} + */ +function translateStatus(status) { + if (status === 'paused') { + return 'pause'; + } + + return status; +} + +export default connect(_mapStateToProps, _mapDispatchToProps, _mergeProps)(YoutubeLargeVideo); diff --git a/react/features/youtube-player/components/native/index.js b/react/features/youtube-player/components/native/index.js new file mode 100644 index 000000000000..90ca23c60c0f --- /dev/null +++ b/react/features/youtube-player/components/native/index.js @@ -0,0 +1,2 @@ +export { default as EnterVideoLinkPrompt } from './EnterVideoLinkPrompt'; +export { default as YoutubeLargeVideo } from './YoutubeLargeVideo'; diff --git a/react/features/youtube-player/components/native/styles.js b/react/features/youtube-player/components/native/styles.js new file mode 100644 index 000000000000..664646726b0d --- /dev/null +++ b/react/features/youtube-player/components/native/styles.js @@ -0,0 +1,13 @@ +// @flow + +/** + * The style of toolbar buttons. + */ +export default { + youtubeVideoContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'column', + justifyContent: 'center' + } +}; diff --git a/react/features/youtube-player/index.js b/react/features/youtube-player/index.js new file mode 100644 index 000000000000..a29aa08e02fa --- /dev/null +++ b/react/features/youtube-player/index.js @@ -0,0 +1,6 @@ +export * from './actions'; +export * from './actionTypes'; +export * from './components'; + +import './middleware'; +import './reducer'; diff --git a/react/features/youtube-player/middleware.js b/react/features/youtube-player/middleware.js new file mode 100644 index 000000000000..d193a639a45f --- /dev/null +++ b/react/features/youtube-player/middleware.js @@ -0,0 +1,183 @@ +// @flow + +import { CONFERENCE_LEFT, getCurrentConference } from '../base/conference'; +import { + PARTICIPANT_LEFT, + getLocalParticipant, + participantJoined, + participantLeft, + pinParticipant +} from '../base/participants'; +import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; + +import { TOGGLE_SHARED_VIDEO, SET_SHARED_VIDEO_STATUS } from './actionTypes'; +import { setSharedVideoStatus, showEnterVideoLinkPrompt } from './actions'; + +const SHARED_VIDEO = 'shared-video'; + +/** + * Middleware that captures actions related to YouTube 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; + const state = getState(); + const conference = getCurrentConference(state); + const localParticipantId = getLocalParticipant(state)?.id; + const { videoId, status, ownerId, time } = action; + + switch (action.type) { + case TOGGLE_SHARED_VIDEO: + _toggleSharedVideo(store, next, action); + break; + case CONFERENCE_LEFT: + dispatch(setSharedVideoStatus('', 'stop', 0, '')); + break; + case PARTICIPANT_LEFT: + if (action.participant.id === action.ownerId) { + dispatch(setSharedVideoStatus('', 'stop', 0, '')); + } + break; + case SET_SHARED_VIDEO_STATUS: + if (localParticipantId === ownerId) { + sendShareVideoCommand(videoId, status, conference, localParticipantId, time); + } + break; + } + + return next(action); +}); + +/** + * Set up state change listener to perform maintenance tasks when the conference + * is left or failed, e.g. clear messages or close the chat modal if it's left + * open. + */ +StateListenerRegistry.register( + state => getCurrentConference(state), + (conference, store, previousConference) => { + if (conference && conference !== previousConference) { + conference.addCommandListener(SHARED_VIDEO, + ({ value, attributes }) => { + + const { dispatch, getState } = store; + const { from } = attributes; + const localParticipantId = getLocalParticipant(getState()).id; + const status = attributes.state; + + if ([ 'playing', 'pause', 'start' ].includes(status)) { + handleSharingVideoStatus(store, value, attributes, conference); + } else if (status === 'stop') { + dispatch(participantLeft(value, conference)); + if (localParticipantId !== from) { + dispatch(setSharedVideoStatus(value, 'stop', 0, from)); + } + } + } + ); + } + }); + +/** + * 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} videoId - The YoutubeId 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, videoId, { state, time, from }, conference) { + const { dispatch, getState } = store; + const localParticipantId = getLocalParticipant(getState()).id; + const oldStatus = getState()['features/youtube-player']?.status; + + if (state === 'start' || ![ 'playing', 'pause', 'start' ].includes(oldStatus)) { + dispatch(participantJoined({ + conference, + id: videoId, + isFakeParticipant: true, + avatarURL: `https://img.youtube.com/vi/${videoId}/0.jpg`, + name: 'YouTube' + })); + + dispatch(pinParticipant(videoId)); + } + + if (localParticipantId !== from) { + dispatch(setSharedVideoStatus(videoId, state, time, from)); + } +} + +/** + * Dispatches shared video status. + * + * @param {Store} store - The redux store. + * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the + * specified {@code action} in the specified {@code store}. + * @param {Action} action - The redux action which is + * being dispatched in the specified {@code store}. + * @returns {Function} + */ +function _toggleSharedVideo(store, next, action) { + const { dispatch, getState } = store; + const state = getState(); + const { videoId, ownerId, status } = state['features/youtube-player']; + const localParticipant = getLocalParticipant(state); + + if (status === 'playing' || status === 'start' || status === 'pause') { + if (ownerId === localParticipant.id) { + dispatch(setSharedVideoStatus(videoId, 'stop', 0, localParticipant.id)); + } + } else { + dispatch(showEnterVideoLinkPrompt(id => _onVideoLinkEntered(store, id))); + } + + return next(action); +} + +/** + * Sends SHARED_VIDEO start command. + * + * @param {Store} store - The redux store. + * @param {string} id - The youtube id of the video to be shared. + * @returns {void} + */ +function _onVideoLinkEntered(store, id) { + const { dispatch, getState } = store; + const conference = getCurrentConference(getState()); + + if (conference) { + const localParticipant = getLocalParticipant(getState()); + + dispatch(setSharedVideoStatus(id, 'start', 0, localParticipant.id)); + } +} + +/* eslint-disable max-params */ + +/** + * Sends SHARED_VIDEO command. + * + * @param {string} id - The youtube 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} + */ +function sendShareVideoCommand(id, status, conference, localParticipantId, time) { + conference.sendCommandOnce(SHARED_VIDEO, { + value: id, + attributes: { + from: localParticipantId, + state: status, + time + } + }); +} diff --git a/react/features/youtube-player/reducer.js b/react/features/youtube-player/reducer.js new file mode 100644 index 000000000000..19653324a5d8 --- /dev/null +++ b/react/features/youtube-player/reducer.js @@ -0,0 +1,24 @@ +// @flow +import { ReducerRegistry } from '../base/redux'; + +import { SET_SHARED_VIDEO_STATUS } from './actionTypes'; + +/** + * Reduces the Redux actions of the feature features/youtube-player. + */ +ReducerRegistry.register('features/youtube-player', (state = {}, action) => { + const { videoId, status, time, ownerId } = action; + + switch (action.type) { + case SET_SHARED_VIDEO_STATUS: + return { + ...state, + videoId, + status, + time, + ownerId + }; + default: + return state; + } +});