diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/base.js b/bigbluebutton-html5/imports/api/audio/client/bridge/base.js index c2155b08d7e037e7d27331d94cc72c813151599c..55fefe9087a1881d8c357090ea5362e3fa852186 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/base.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/base.js @@ -17,6 +17,7 @@ export default class BaseAudioBridge { ended: 'ended', failed: 'failed', reconnecting: 'reconnecting', + autoplayBlocked: 'autoplayBlocked', }; } diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js b/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js index 5404bffa85a5f462c5aa2cd7406a0d66dcaa571e..7be1fe97179e65c6f1731308b4f468f754071cb2 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js @@ -64,16 +64,27 @@ export default class KurentoAudioBridge extends BaseAudioBridge { audioTag.pause(); audioTag.srcObject = stream; audioTag.muted = false; - audioTag.play().catch((e) => { - const tagFailedEvent = new CustomEvent('mediaTagPlayFailed', { detail: { mediaTag: audioTag } }); - window.dispatchEvent(tagFailedEvent); - logger.warn({ - logCode: 'sfuaudiobridge_play_error', - extraInfo: { error: e }, - }, 'Could not play audio tag, emit mediaTagPlayFailed event'); + audioTag.play() + .then(() => { + resolve(this.callback({ status: this.baseCallStates.started })); + }) + .catch((e) => { + const tagFailedEvent = new CustomEvent('audioPlayFailed', { detail: { mediaElement: audioTag } }); + window.dispatchEvent(tagFailedEvent); + logger.warn({ + logCode: 'sfuaudiobridge_play_error', + extraInfo: { error: e }, + }, 'Could not play audio tag, emit mediaTagPlayFailed event'); + resolve(this.callback({ + status: this.baseCallStates.autoplayBlocked, + })); + }); + } else { + this.callback({ + status: this.baseCallStates.failed, + error: this.baseErrorCodes.CONNECTION_ERROR, }); } - resolve(this.callback({ status: this.baseCallStates.started })); }; const onFail = (error) => { diff --git a/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js b/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js index 71c29613c96d0f1c6ff983fb25ab483e4966b073..c83d2c7b55a3b661aa93b462767dc66d085bbcdd 100755 --- a/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js +++ b/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js @@ -1,4 +1,3 @@ -import Users from '/imports/api/users'; import Auth from '/imports/ui/services/auth'; import BridgeService from './service'; import { fetchWebRTCMappedStunTurnServers } from '/imports/utils/fetchStunTurnServers'; @@ -18,8 +17,6 @@ const getUserId = () => Auth.userID; const getMeetingId = () => Auth.meetingID; -const getUsername = () => Users.findOne({ userId: getUserId() }).name; - const getSessionToken = () => Auth.sessionToken; export default class KurentoScreenshareBridge { @@ -38,13 +35,33 @@ export default class KurentoScreenshareBridge { logger, }; + const onSuccess = () => { + const { webRtcPeer } = window.kurentoManager.kurentoVideo; + if (webRtcPeer) { + const screenshareTag = document.getElementById(SCREENSHARE_VIDEO_TAG); + const stream = webRtcPeer.getRemoteStream(); + screenshareTag.pause(); + screenshareTag.srcObject = stream; + screenshareTag.muted = false; + screenshareTag.play().catch((e) => { + const tagFailedEvent = new CustomEvent('screensharePlayFailed', + { detail: { mediaElement: screenshareTag } }); + window.dispatchEvent(tagFailedEvent); + logger.warn({ + logCode: 'sfuscreenshareview_play_error', + extraInfo: { error: e }, + }, 'Could not play screenshare viewer media tag, emit screensharePlayFailed'); + }); + } + }; + window.kurentoWatchVideo( SCREENSHARE_VIDEO_TAG, BridgeService.getConferenceBridge(), getUserId(), getMeetingId(), null, - null, + onSuccess, options, ); } diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx index 358c3c676ba81fb999803e53a57061c0338456f9..737318230f052b83f103f3c32e39c9fcf829df9a 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/component.jsx @@ -13,7 +13,7 @@ import AudioSettings from '../audio-settings/component'; import EchoTest from '../echo-test/component'; import Help from '../help/component'; import AudioDial from '../audio-dial/component'; - +import AudioAutoplayPrompt from '../autoplay/component'; const propTypes = { intl: intlShape.isRequired, @@ -44,6 +44,8 @@ const propTypes = { isIEOrEdge: PropTypes.bool.isRequired, hasMediaDevices: PropTypes.bool.isRequired, formattedTelVoice: PropTypes.string.isRequired, + autoplayBlocked: PropTypes.bool.isRequired, + handleAllowAutoplay: PropTypes.func.isRequired, }; const defaultProps = { @@ -109,6 +111,10 @@ const intlMessages = defineMessages({ id: 'app.audioModal.ariaTitle', description: 'aria label for modal title', }, + autoplayPromptTitle: { + id: 'app.audioModal.autoplayBlockedDesc', + description: 'Message for autoplay audio block', + }, }); class AudioModal extends Component { @@ -145,7 +151,12 @@ class AudioModal extends Component { title: intlMessages.audioDialTitle, component: () => this.renderAudioDial(), }, + autoplayBlocked: { + title: intlMessages.autoplayPromptTitle, + component: () => this.renderAutoplayOverlay(), + }, }; + this.failedMediaElements = []; } componentDidMount() { @@ -169,6 +180,13 @@ class AudioModal extends Component { } } + componentDidUpdate(prevProps) { + const { autoplayBlocked, closeModal } = this.props; + if (autoplayBlocked !== prevProps.autoplayBlocked) { + autoplayBlocked ? this.setState({ content: 'autoplayBlocked' }) : closeModal(); + } + } + componentWillUnmount() { const { isEchoTest, @@ -437,6 +455,15 @@ class AudioModal extends Component { ); } + renderAutoplayOverlay() { + const { handleAllowAutoplay } = this.props; + return ( + <AudioAutoplayPrompt + handleAllowAutoplay={handleAllowAutoplay} + /> + ); + } + render() { const { intl, diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx index 07cb94c2b80f9c68e45911a550e688370cdd760b..924a10c9284f394d9f5f82b4b0a97e6fd0ecc3b0 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/container.jsx @@ -55,7 +55,23 @@ export default lockContextContainer(withModalMounter(withTracker(({ mountModal, throw error; }); }, - joinListenOnly: () => Service.joinListenOnly().then(() => mountModal(null)), + joinListenOnly: () => { + const call = new Promise((resolve) => { + Service.joinListenOnly().then(() => { + // Autoplay block wasn't triggered. Close the modal. If autoplay was + // blocked, that'll be handled in the modal component when then + // prop transitions to a state where it was handled OR the user opts + // to close the modal. + if (!Service.autoplayBlocked()) { + mountModal(null); + } + resolve(); + }); + }); + return call.catch((error) => { + throw error; + }); + }, leaveEchoTest: () => { if (!Service.isEchoTest()) { return Promise.resolve(); @@ -85,5 +101,7 @@ export default lockContextContainer(withModalMounter(withTracker(({ mountModal, isMobileNative: navigator.userAgent.toLowerCase().includes('bbbnative'), isIEOrEdge: browser().name === 'edge' || browser().name === 'ie', hasMediaDevices: deviceInfo.hasMediaDevices, + autoplayBlocked: Service.autoplayBlocked(), + handleAllowAutoplay: () => Service.handleAllowAutoplay(), }); })(AudioModalContainer))); diff --git a/bigbluebutton-html5/imports/ui/components/audio/autoplay/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/autoplay/component.jsx new file mode 100644 index 0000000000000000000000000000000000000000..33d48b801ba7d7cdb80d30e709bcae0b2b0b17f6 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/autoplay/component.jsx @@ -0,0 +1,48 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import Button from '/imports/ui/components/button/component'; +import { defineMessages, intlShape, injectIntl } from 'react-intl'; +import { styles } from './styles'; + +const intlMessages = defineMessages({ + confirmLabel: { + id: 'app.audioModal.yes', + description: 'Hear yourself yes', + }, + confirmAriaLabel: { + id: 'app.audioModal.yes.arialabel', + description: 'provides better context for yes btn label', + }, +}); + +const propTypes = { + handleAllowAutoplay: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +class AudioAutoplayPrompt extends PureComponent { + render() { + const { + intl, + handleAllowAutoplay, + } = this.props; + return ( + <span className={styles.autoplayPrompt}> + <Button + className={styles.button} + label={intl.formatMessage(intlMessages.confirmLabel)} + aria-label={intl.formatMessage(intlMessages.confirmAriaLabel)} + icon="thumbs_up" + circle + color="success" + size="jumbo" + onClick={handleAllowAutoplay} + /> + </span> + ); + } +} + +export default injectIntl(AudioAutoplayPrompt); + +AudioAutoplayPrompt.propTypes = propTypes; diff --git a/bigbluebutton-html5/imports/ui/components/audio/autoplay/styles.scss b/bigbluebutton-html5/imports/ui/components/audio/autoplay/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..9bc3faa24bbf7bd44c385becb9c96712dcc8fab6 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/autoplay/styles.scss @@ -0,0 +1,18 @@ +@import "/imports/ui/stylesheets/variables/_all"; + +.autoplayPrompt { + margin-top: auto; + margin-bottom: auto; +} + +.button { + &:focus { + outline: none !important; + } + + span:last-child { + color: black; + font-size: 1rem; + font-weight: 600; + } +} diff --git a/bigbluebutton-html5/imports/ui/components/audio/service.js b/bigbluebutton-html5/imports/ui/components/audio/service.js index 38cd9812f58bdb46be72dbeba3c6888451dfcac4..3f1601f045538b3d905cfecdfb5f11e55d437dfd 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/service.js +++ b/bigbluebutton-html5/imports/ui/components/audio/service.js @@ -81,4 +81,6 @@ export default { error: () => AudioManager.error, isUserModerator: () => Users.findOne({ userId: Auth.userID }).role === ROLE_MODERATOR, currentUser, + autoplayBlocked: () => AudioManager.autoplayBlocked, + handleAllowAutoplay: () => AudioManager.handleAllowAutoplay(), }; diff --git a/bigbluebutton-html5/imports/ui/components/media/autoplay-overlay/component.jsx b/bigbluebutton-html5/imports/ui/components/media/autoplay-overlay/component.jsx new file mode 100644 index 0000000000000000000000000000000000000000..829c7a015fc52d11205b6df028e5bf8ef01efad2 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/media/autoplay-overlay/component.jsx @@ -0,0 +1,53 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Button from '/imports/ui/components/button/component'; +import { styles } from './styles'; + +const propTypes = { + autoplayBlockedDesc: PropTypes.string.isRequired, + autoplayAllowLabel: PropTypes.string.isRequired, + handleAllowAutoplay: PropTypes.func.isRequired, + intl: PropTypes.objectOf(Object).isRequired, +}; + +const intlMessages = defineMessages({ + autoplayAlertDesc: { + id: 'app.media.autoplayAlertDesc', + description: 'Description for the autoplay alert title', + }, +}); + +class AutoplayOverlay extends PureComponent { + render() { + const { + intl, + handleAllowAutoplay, + autoplayBlockedDesc, + autoplayAllowLabel, + } = this.props; + return ( + <div className={styles.autoplayOverlay}> + <div className={styles.title}> + { intl.formatMessage(intlMessages.autoplayAlertDesc) } + </div> + <div className={styles.autoplayOverlayContent}> + <div className={styles.label}> + {autoplayBlockedDesc} + </div> + <Button + color="primary" + label={autoplayAllowLabel} + onClick={handleAllowAutoplay} + role="button" + size="lg" + /> + </div> + </div> + ); + } +} + +AutoplayOverlay.propTypes = propTypes; + +export default injectIntl(AutoplayOverlay); diff --git a/bigbluebutton-html5/imports/ui/components/media/autoplay-overlay/styles.scss b/bigbluebutton-html5/imports/ui/components/media/autoplay-overlay/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..7b7eee790ec02315d8a6fafbab59636884e4e1de --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/media/autoplay-overlay/styles.scss @@ -0,0 +1,31 @@ +@import "/imports/ui/stylesheets/variables/_all"; + +.autoplayOverlayContent { + text-align: center; + margin-top: 8px; +} +.title { + display: block; + font-size: var(--font-size-large); + text-align: center; +} +.label { + display: block; + font-size: var(--font-size-base); + text-align: center; + margin-bottom: 12px; +} +.autoplayOverlay { + display: flex; + justify-content: center; + flex-direction: column; + background: rgba(0, 0, 0, 1); + height: 100%; + width: 100%; + color: var(--color-white); + font-size: var(--font-size-large); + border-radius: 5px; + position: absolute; + z-index: 1; + text-align: center; +} diff --git a/bigbluebutton-html5/imports/ui/components/media/component.jsx b/bigbluebutton-html5/imports/ui/components/media/component.jsx index 736d234e12dde3fe6aafd19e2c28145c272b3a03..c0ee0b6777362fe058f1c9c5061b4ccc60d01a2a 100644 --- a/bigbluebutton-html5/imports/ui/components/media/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/media/component.jsx @@ -33,56 +33,6 @@ export default class Media extends Component { constructor(props) { super(props); this.refContainer = React.createRef(); - - this.failedTags = []; - this.listeningToTagPlayFailed = false; - this.monitorMediaTagPlayFailures(); - } - - monitorMediaTagPlayFailures() { - const handleFailTagEvent = (e) => { - e.stopPropagation(); - this.failedTags.push(e.detail.mediaTag); - - if (!this.listeningToTagPlayFailed) { - this.listeningToTagPlayFailed = true; - // Monitor user action events so we can play and flush all the failed tags - // in the queue when the user performs one of them - window.addEventListener('click', flushFailedTags); - window.addEventListener('auxclick', flushFailedTags); - window.addEventListener('keydown', flushFailedTags); - window.addEventListener('touchstart', flushFailedTags); - } - }; - - const flushFailedTags = () => { - window.removeEventListener('click', flushFailedTags); - window.removeEventListener('auxclick', flushFailedTags); - window.removeEventListener('keydown', flushFailedTags); - window.removeEventListener('touchstart', flushFailedTags); - - while (this.failedTags.length) { - const mediaTag = this.failedTags.shift(); - if (mediaTag) { - mediaTag.play().catch((e) => { - // Ignore the error for now. - }); - } - } - - this.listeningToTagPlayFailed = false; - }; - - // Monitor tag play failure events, probably due to autoplay. The callback - // puts the failed tags in a queue which will be flushed on a user action - // by the listeners created @handleFailTagEvent. Once the queue is flushed, all - // user action listeners are removed since the autoplay restriction should be over. - // Every media tag in the app should have a then/catch handler and emit - // this event accordingly so we can try to circumvent autoplay without putting - // a UI block/prompt. - // If a tag fail to play again for some odd reason, the listeners will be - // reattached (see this.listeningToTagPlayFailed) and flushFailedTags runs again - window.addEventListener('mediaTagPlayFailed', handleFailTagEvent); } componentWillUpdate() { diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx b/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx index 4221d82fa409e93d465e3a4952805b5bbccad7a5..aae6e278ce42a923719911e56a6f206dd1cfb38d 100755 --- a/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/screenshare/component.jsx @@ -5,12 +5,19 @@ import _ from 'lodash'; import FullscreenService from '../fullscreen-button/service'; import FullscreenButtonContainer from '../fullscreen-button/container'; import { styles } from './styles'; +import AutoplayOverlay from '../media/autoplay-overlay/component'; const intlMessages = defineMessages({ screenShareLabel: { id: 'app.screenshare.screenShareLabel', description: 'screen share area element label', }, + autoplayBlockedDesc: { + id: 'app.media.screenshare.autoplayBlockedDesc', + }, + autoplayAllowLabel: { + id: 'app.media.screenshare.autoplayAllowLabel', + }, }); const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen; @@ -21,10 +28,14 @@ class ScreenshareComponent extends React.Component { this.state = { loaded: false, isFullscreen: false, + autoplayBlocked: false, }; this.onVideoLoad = this.onVideoLoad.bind(this); this.onFullscreenChange = this.onFullscreenChange.bind(this); + this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this); + this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this); + this.failedMediaElements = []; } componentDidMount() { @@ -32,6 +43,7 @@ class ScreenshareComponent extends React.Component { presenterScreenshareHasStarted(); this.screenshareContainer.addEventListener('fullscreenchange', this.onFullscreenChange); + window.addEventListener('screensharePlayFailed', this.handlePlayElementFailed); } componentWillReceiveProps(nextProps) { @@ -50,6 +62,7 @@ class ScreenshareComponent extends React.Component { presenterScreenshareHasEnded(); unshareScreen(); this.screenshareContainer.removeEventListener('fullscreenchange', this.onFullscreenChange); + window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed); } onVideoLoad() { @@ -64,6 +77,32 @@ class ScreenshareComponent extends React.Component { } } + handleAllowAutoplay() { + const { autoplayBlocked } = this.state; + + window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed); + while (this.failedMediaElements.length) { + const mediaElement = this.failedMediaElements.shift(); + if (mediaElement) { + mediaElement.play().catch(() => { + // Ignore the error for now. + }); + } + } + if (autoplayBlocked) { this.setState({ autoplayBlocked: false }); } + } + + handlePlayElementFailed(e) { + const { mediaElement } = e.detail; + const { autoplayBlocked } = this.state; + + e.stopPropagation(); + this.failedMediaElements.push(mediaElement); + if (!autoplayBlocked) { + this.setState({ autoplayBlocked: true }); + } + } + renderFullscreenButton() { const { intl } = this.props; const { isFullscreen } = this.state; @@ -82,7 +121,8 @@ class ScreenshareComponent extends React.Component { } render() { - const { loaded } = this.state; + const { loaded, autoplayBlocked } = this.state; + const { intl } = this.props; return ( [!loaded @@ -93,6 +133,16 @@ class ScreenshareComponent extends React.Component { /> ) : null, + !autoplayBlocked + ? null + : ( + <AutoplayOverlay + key={_.uniqueId('screenshareAutoplayOverlay')} + autoplayBlockedDesc={intl.formatMessage(intlMessages.autoplayBlockedDesc)} + autoplayAllowLabel={intl.formatMessage(intlMessages.autoplayAllowLabel)} + handleAllowAutoplay={this.handleAllowAutoplay} + /> + ), ( <div className={styles.screenshareContainer} diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx index 80777f33e37a400579fce8b96f4c349871ef0f20..5d3cdd5441c456cb01351269947c8c4a1df83a04 100644 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/component.jsx @@ -6,6 +6,7 @@ import _ from 'lodash'; import { styles } from './styles'; import VideoListItem from './video-list-item/component'; import { withDraggableConsumer } from '../../media/webcam-draggable-overlay/context'; +import AutoplayOverlay from '../../media/autoplay-overlay/component'; const propTypes = { users: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -30,6 +31,12 @@ const intlMessages = defineMessages({ unfocusDesc: { id: 'app.videoDock.webcamUnfocusDesc', }, + autoplayBlockedDesc: { + id: 'app.videoDock.autoplayBlockedDesc', + }, + autoplayAllowLabel: { + id: 'app.videoDock.autoplayAllowLabel', + }, }); const findOptimalGrid = (canvasWidth, canvasHeight, gutter, aspectRatio, numItems, columns = 1) => { @@ -66,17 +73,21 @@ class VideoList extends Component { rows: 1, filledArea: 0, }, + autoplayBlocked: false, }; this.ticking = false; this.grid = null; this.canvas = null; + this.failedMediaElements = []; this.handleCanvasResize = _.throttle(this.handleCanvasResize.bind(this), 66, { leading: true, trailing: true, }); this.setOptimalGrid = this.setOptimalGrid.bind(this); + this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this); + this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this); } componentDidMount() { @@ -90,10 +101,12 @@ class VideoList extends Component { this.handleCanvasResize(); window.addEventListener('resize', this.handleCanvasResize, false); + window.addEventListener('videoPlayFailed', this.handlePlayElementFailed); } componentWillUnmount() { window.removeEventListener('resize', this.handleCanvasResize, false); + window.removeEventListener('videoPlayFailed', this.handlePlayElementFailed); } setOptimalGrid() { @@ -128,6 +141,32 @@ class VideoList extends Component { }); } + handleAllowAutoplay() { + const { autoplayBlocked } = this.state; + + window.removeEventListener('videoPlayFailed', this.handlePlayElementFailed); + while (this.failedMediaElements.length) { + const mediaElement = this.failedMediaElements.shift(); + if (mediaElement) { + mediaElement.play().catch(() => { + // Ignore the error for now. + }); + } + } + if (autoplayBlocked) { this.setState({ autoplayBlocked: false }); } + } + + handlePlayElementFailed(e) { + const { mediaElement } = e.detail; + const { autoplayBlocked } = this.state; + + e.stopPropagation(); + this.failedMediaElements.push(mediaElement); + if (!autoplayBlocked) { + this.setState({ autoplayBlocked: true }); + } + } + handleVideoFocus(id) { const { focusedId } = this.state; this.setState({ @@ -196,8 +235,8 @@ class VideoList extends Component { } render() { - const { users } = this.props; - const { optimalGrid } = this.state; + const { users, intl } = this.props; + const { optimalGrid, autoplayBlocked } = this.state; const canvasClassName = cx({ [styles.videoCanvas]: true, @@ -230,6 +269,13 @@ class VideoList extends Component { {this.renderVideoList()} </div> )} + { !autoplayBlocked ? null : ( + <AutoplayOverlay + autoplayBlockedDesc={intl.formatMessage(intlMessages.autoplayBlockedDesc)} + autoplayAllowLabel={intl.formatMessage(intlMessages.autoplayAllowLabel)} + handleAllowAutoplay={this.handleAllowAutoplay} + /> + )} </div> ); } diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx index dea5bfa6a2fea50cdddb63a165ce45a8db814043..66ac7eebd562e71695c97d3a7500f99395b42f30 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-list/video-list-item/component.jsx @@ -65,7 +65,7 @@ class VideoListItem extends Component { const playElement = (elem) => { if (elem.paused) { elem.play().catch((error) => { - const tagFailedEvent = new CustomEvent('mediaTagPlayFailed', { detail: { mediaTag: elem } }); + const tagFailedEvent = new CustomEvent('videoPlayFailed', { detail: { mediaTag: elem } }); window.dispatchEvent(tagFailedEvent); logger.warn({ logCode: 'videolistitem_component_play_error', diff --git a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js index f550db0d137ce20132aae093b4e62626c220b966..e2458d4ffbab95101da3793d91fd17654e1c2cee 100755 --- a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js +++ b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js @@ -19,6 +19,7 @@ const CALL_STATES = { ENDED: 'ended', FAILED: 'failed', RECONNECTING: 'reconnecting', + AUTOPLAY_BLOCKED: 'autoplayBlocked', }; class AudioManager { @@ -40,9 +41,12 @@ class AudioManager { error: null, outputDeviceId: null, muteHandle: null, + autoplayBlocked: false, }); this.useKurento = Meteor.settings.public.kurento.enableListenOnly; + this.failedMediaElements = []; + this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this); } init(userData) { @@ -203,6 +207,8 @@ class AudioManager { logger.info({ logCode: 'audiomanager_join_listenonly', extraInfo: { logType: 'user_action' } }, 'user requested to connect to audio conference as listen only'); + window.addEventListener('audioPlayFailed', this.handlePlayElementFailed); + return this.onAudioJoining() .then(() => Promise.race([ bridge.joinAudio(callOptions, this.callStateCallback.bind(this)), @@ -299,6 +305,8 @@ class AudioManager { this.isConnecting = false; this.isHangingUp = false; this.isListenOnly = false; + this.autoplayBlocked = false; + this.failedMediaElements = []; if (this.inputStream) { window.defaultInputStream.forEach(track => track.stop()); @@ -314,6 +322,7 @@ class AudioManager { } window.parent.postMessage({ response: 'notInAudio' }, '*'); + window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed); } callStateCallback(response) { @@ -323,6 +332,7 @@ class AudioManager { ENDED, FAILED, RECONNECTING, + AUTOPLAY_BLOCKED, } = CALL_STATES; const { @@ -358,6 +368,10 @@ class AudioManager { logger.info({ logCode: 'audio_reconnecting' }, 'Attempting to reconnect audio'); this.notify(this.intl.formatMessage(this.messages.info.RECONNECTING_AUDIO), true); this.playHangUpSound(); + } else if (status === AUTOPLAY_BLOCKED) { + this.autoplayBlocked = true; + this.onAudioJoin(); + resolve(AUTOPLAY_BLOCKED); } }); } @@ -468,6 +482,29 @@ class AudioManager { audioIcon, ); } + + handleAllowAutoplay() { + window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed); + while (this.failedMediaElements.length) { + const mediaElement = this.failedMediaElements.shift(); + if (mediaElement) { + mediaElement.play().catch(() => { + // Ignore the error for now. + }); + } + } + this.autoplayBlocked = false; + } + + handlePlayElementFailed(e) { + const { mediaElement } = e.detail; + + e.stopPropagation(); + this.failedMediaElements.push(mediaElement); + if (!this.autoplayBlocked) { + this.autoplayBlocked = true; + } + } } const audioManager = new AudioManager(); diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json index 5518bee0f6cad64ad50d3f3d05438c5f1b504562..437f90c501e9b113656b66d990ea9a3eb0684a77 100755 --- a/bigbluebutton-html5/private/locales/en.json +++ b/bigbluebutton-html5/private/locales/en.json @@ -103,9 +103,12 @@ "app.userList.userOptions.enableNote": "Shared notes are now enabled", "app.userList.userOptions.enableOnlyModeratorWebcam": "You can enable your webcam now, everyone will see you", "app.media.label": "Media", + "app.media.autoplayAlertDesc": "Allow Access", "app.media.screenshare.start": "Screenshare has started", "app.media.screenshare.end": "Screenshare has ended", "app.media.screenshare.safariNotSupported": "Screenshare is currently not supported by Safari. Please, use Firefox or Google Chrome.", + "app.media.screenshare.autoplayBlockedDesc": "We need your permission to show you the presenter's screen.", + "app.media.screenshare.autoplayAllowLabel": "View shared screen", "app.meeting.ended": "This session has ended", "app.meeting.meetingTimeRemaining": "Meeting time remaining: {0}", "app.meeting.meetingTimeHasEnded": "Time ended. Meeting will close soon", @@ -406,6 +409,7 @@ "app.audioModal.audioDialTitle": "Join using your phone", "app.audioDial.audioDialDescription": "Dial", "app.audioDial.audioDialConfrenceText": "and enter the conference PIN number:", + "app.audioModal.autoplayBlockedDesc": "We need your permission to play audio. Do you accept?", "app.audioDial.tipIndicator": "Tip", "app.audioDial.tipMessage": "Press the '0' key on your phone to mute/unmute yourself.", "app.audioModal.connecting": "Connecting", @@ -616,6 +620,8 @@ "app.videoDock.webcamFocusDesc": "Focus the selected webcam", "app.videoDock.webcamUnfocusLabel": "Unfocus", "app.videoDock.webcamUnfocusDesc": "Unfocus the selected webcam", + "app.videoDock.autoplayBlockedDesc": "We need your permission to show you other users' webcams.", + "app.videoDock.autoplayAllowLabel": "View webcams", "app.invitation.title": "Breakout room invitation", "app.invitation.confirm": "Invite", "app.createBreakoutRoom.title": "Breakout Rooms", diff --git a/bigbluebutton-html5/public/compatibility/kurento-extension.js b/bigbluebutton-html5/public/compatibility/kurento-extension.js index 173830a6a08fdf98fae39b0f54b536874a17d26e..0394f67e5cb925e3d1173f732345513e12b71327 100755 --- a/bigbluebutton-html5/public/compatibility/kurento-extension.js +++ b/bigbluebutton-html5/public/compatibility/kurento-extension.js @@ -301,6 +301,11 @@ Kurento.prototype.startResponse = function (message) { extraInfo: { sfuResponse: message } }, `Start request accepted for ${message.type}`); this.webRtcPeer.processAnswer(message.sdpAnswer); + // audio calls gets their success callback in a subsequent step (@webRTCAudioSuccess) + // due to legacy messaging which I don't intend to break now - prlanzarin + if (message.type === 'screenshare') { + this.onSuccess() + } } }; @@ -470,7 +475,6 @@ Kurento.prototype.viewer = function () { mediaConstraints: { audio: false, }, - remoteVideo: document.getElementById(this.renderTag), onicecandidate: (candidate) => { this.onIceCandidate(candidate, this.RECV_ROLE); },