From e0e4ce8b380f2928b7095c09766b7ffee06cc6e4 Mon Sep 17 00:00:00 2001 From: Maureen Date: Wed, 24 Feb 2021 09:49:33 -0800 Subject: [PATCH] changes to inlineAudio to support tts autoplay --- .../templates/instructions/InlineAudio.jsx | 90 +++++++++++++++++-- .../templates/instructions/InlineAudioTest.js | 42 ++++++++- 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/apps/src/templates/instructions/InlineAudio.jsx b/apps/src/templates/instructions/InlineAudio.jsx index 53ee6dd30343c..42be1884d8cdb 100644 --- a/apps/src/templates/instructions/InlineAudio.jsx +++ b/apps/src/templates/instructions/InlineAudio.jsx @@ -82,6 +82,20 @@ const styles = { } }; +// pulled from the example here https://developers.google.com/web/updates/2018/11/web-audio-autoplay +const AUDIO_ENABLING_DOM_EVENTS = [ + 'click', + 'contextmenu', + 'auxclick', + 'dblclick', + 'mousedown', + 'mouseup', + 'pointerup', + 'touchend', + 'keydown', + 'keyup' +]; + class InlineAudio extends React.Component { static propTypes = { assetUrl: PropTypes.func.isRequired, @@ -90,6 +104,11 @@ class InlineAudio extends React.Component { src: PropTypes.string, message: PropTypes.string, style: PropTypes.object, + ttsAutoplayEnabled: PropTypes.bool, + + // when we need to wait for DOM event to trigger audio autoplay + // this is the element ID that we'll be listening to + autoplayTriggerElementId: PropTypes.string, // Provided by redux // To Log TTS usage @@ -103,11 +122,26 @@ class InlineAudio extends React.Component { playing: false, error: false, hover: false, - loaded: false + loaded: false, + autoplayed: false }; + constructor(props) { + super(props); + this.autoplayAudio = this.autoplayAudio.bind(this); + this.autoplayTriggerElement = null; + } + componentDidMount() { this.getAudioElement(); + if (this.props.ttsAutoplayEnabled && !this.state.autoplayed) { + const {autoplayTriggerElementId} = this.props; + this.autoplayTriggerElement = autoplayTriggerElementId + ? document.getElementById(autoplayTriggerElementId) + : document; + + this.playAudio(); + } } componentWillUpdate(nextProps) { @@ -152,7 +186,8 @@ class InlineAudio extends React.Component { audio.addEventListener('ended', e => { this.setState({ - playing: false + playing: false, + autoplayed: this.props.ttsAutoplayEnabled }); }); @@ -193,9 +228,7 @@ class InlineAudio extends React.Component { this.state.playing ? this.pauseAudio() : this.playAudio(); }; - playAudio() { - this.getAudioElement().play(); - this.setState({playing: true}); + recordPlayEvent() { firehoseClient.putRecord({ study: 'tts-play', study_group: 'v1', @@ -210,6 +243,49 @@ class InlineAudio extends React.Component { }); } + // adds event listeners to the DOM which trigger audio + // when a significant enough user interaction has happened + addAudioAutoplayTrigger() { + AUDIO_ENABLING_DOM_EVENTS.forEach(event => { + this.autoplayTriggerElement.addEventListener(event, this.autoplayAudio); + }); + } + + removeAudioAutoplayTrigger() { + AUDIO_ENABLING_DOM_EVENTS.forEach(event => { + this.autoplayTriggerElement.removeEventListener( + event, + this.autoplayAudio + ); + }); + } + + playAudio() { + return this.getAudioElement() + .play() + .then(() => { + this.setState({playing: true}); + this.recordPlayEvent(); + }) + .catch(err => { + const shouldAutoPlay = + this.props.ttsAutoplayEnabled && !this.state.autoplayed; + + // there wasn't significant enough user interaction to play audio automatically + // for more information about this issue on Chrome, see + // https://developers.google.com/web/updates/2017/09/autoplay-policy-changes + if (err instanceof DOMException && shouldAutoPlay) { + this.addAudioAutoplayTrigger(); + } else { + throw err; + } + }); + } + + autoplayAudio() { + this.playAudio().then(() => this.removeAudioAutoplayTrigger()); + } + pauseAudio() { this.getAudioElement().pause(); this.setState({playing: false}); @@ -276,6 +352,10 @@ class InlineAudio extends React.Component { } } +InlineAudio.defaultProps = { + ttsAutoplayEnabled: false +}; + export const StatelessInlineAudio = Radium(InlineAudio); export default connect(function propsFromStore(state) { return { diff --git a/apps/test/unit/templates/instructions/InlineAudioTest.js b/apps/test/unit/templates/instructions/InlineAudioTest.js index c158332fbb61e..dfaf40224e322 100644 --- a/apps/test/unit/templates/instructions/InlineAudioTest.js +++ b/apps/test/unit/templates/instructions/InlineAudioTest.js @@ -14,7 +14,14 @@ const DEFAULT_PROPS = { style: { button: {}, buttonImg: {} - } + }, + ttsAutoplayEnabled: false +}; + +// this is a helper function which is used in a test to +// wait for all preceeding promises to resolve +const waitForPromises = async () => { + return Promise.resolve(); }; describe('InlineAudio', function() { @@ -104,16 +111,43 @@ describe('InlineAudio', function() { expect(component.exists('.inline-audio')).to.be.true; }); - it('can toggle audio', function() { + it('can toggle audio', async function() { const component = mount(); expect(component.state().playing).to.be.false; component.instance().toggleAudio(); + await waitForPromises(); expect(component.state().playing).to.be.true; component.instance().toggleAudio(); + await waitForPromises(); expect(component.state().playing).to.be.false; }); + it('autoplays if autoplay of text-to-speech is enabled', async function() { + const component = mount( + + ); + + await waitForPromises(); + expect(component.state().playing).to.be.true; + }); + + it('when playAudio resolves, state.playing set to true', async () => { + const component = mount( + + ); + + expect(component.state().playing).to.be.false; + await component.instance().playAudio(); + expect(component.state().playing).to.be.true; + }); + it('only initializes Audio once', function() { sinon.spy(window, 'Audio'); const component = mount(); @@ -148,7 +182,9 @@ describe('InlineAudio', function() { // Could extend this to have real EventTarget behavior, // then write tests for 'ended' and 'error' events. class FakeAudio { - play() {} + play() { + return Promise.resolve(); + } pause() {} load() {} // EventTarget interface