New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Updates to InlineAudio to support tts autoplay #39159
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: i'm personally of the opinion that we should declare all instance properties in or under the constructor (depending on whether or not they need access to |
||
? 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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens if playAudio() is called more than once? (I think there are some potential race conditions where this might happen?) |
||
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 { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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(<StatelessInlineAudio {...DEFAULT_PROPS} />); | ||
|
||
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() { | ||
maureensturgeon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const component = mount( | ||
<StatelessInlineAudio | ||
assetUrl={function() {}} | ||
ttsAutoplayEnabled={true} | ||
/> | ||
); | ||
|
||
await waitForPromises(); | ||
expect(component.state().playing).to.be.true; | ||
}); | ||
|
||
it('when playAudio resolves, state.playing set to true', async () => { | ||
const component = mount( | ||
<StatelessInlineAudio | ||
assetUrl={function() {}} | ||
ttsAutoplayEnabled={false} | ||
/> | ||
); | ||
|
||
expect(component.state().playing).to.be.false; | ||
await component.instance().playAudio(); | ||
expect(component.state().playing).to.be.true; | ||
}); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I attempted for 2 days to figure out how to stub the DOMException and check that playing audio starts up after the first user interaction but was getting a lot of trouble with the promises involved in that code path and tests not waiting on promises. I involved other engineers and ultimately decided it was not worth the time it was taking. Please forgive this slight lack of coverage. |
||
it('only initializes Audio once', function() { | ||
sinon.spy(window, 'Audio'); | ||
const component = mount(<StatelessInlineAudio {...DEFAULT_PROPS} />); | ||
|
@@ -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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is added, but not being set to true anywhere in the code yet