Skip to content

Commit

Permalink
Merge pull request #39159 from code-dot-org/LP-1561/inline-audio-changes
Browse files Browse the repository at this point in the history
Updates to InlineAudio to support tts autoplay
  • Loading branch information
maureensturgeon committed Feb 24, 2021
2 parents 7d45037 + e0e4ce8 commit ab49d65
Show file tree
Hide file tree
Showing 2 changed files with 124 additions and 8 deletions.
90 changes: 85 additions & 5 deletions apps/src/templates/instructions/InlineAudio.jsx
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -152,7 +186,8 @@ class InlineAudio extends React.Component {

audio.addEventListener('ended', e => {
this.setState({
playing: false
playing: false,
autoplayed: this.props.ttsAutoplayEnabled
});
});

Expand Down Expand Up @@ -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',
Expand All @@ -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});
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 39 additions & 3 deletions apps/test/unit/templates/instructions/InlineAudioTest.js
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
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;
});

it('only initializes Audio once', function() {
sinon.spy(window, 'Audio');
const component = mount(<StatelessInlineAudio {...DEFAULT_PROPS} />);
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit ab49d65

Please sign in to comment.