diff --git a/src/components/library-item/lib-icon--sound-rtl.svg b/src/components/library-item/lib-icon--sound-rtl.svg
new file mode 100644
index 000000000..db193366a
--- /dev/null
+++ b/src/components/library-item/lib-icon--sound-rtl.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/src/components/library-item/lib-icon--sound.svg b/src/components/library-item/lib-icon--sound.svg
new file mode 100644
index 000000000..2ffc76013
--- /dev/null
+++ b/src/components/library-item/lib-icon--sound.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/src/components/library-item/library-item.css b/src/components/library-item/library-item.css
index 5e726a363..79aebf796 100644
--- a/src/components/library-item/library-item.css
+++ b/src/components/library-item/library-item.css
@@ -7,6 +7,7 @@
align-items: center;
justify-content: flex-start;
flex-basis: 160px;
+ position: relative;
height: 160px;
max-width: 160px;
margin: $space;
diff --git a/src/components/library-item/library-item.jsx b/src/components/library-item/library-item.jsx
index 5af6fb159..81eac4801 100644
--- a/src/components/library-item/library-item.jsx
+++ b/src/components/library-item/library-item.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import Box from '../box/box.jsx';
+import PlayButton from '../../containers/play-button.jsx';
import styles from './library-item.css';
import classNames from 'classnames';
@@ -106,8 +107,9 @@ class LibraryItemComponent extends React.PureComponent {
) : (
{/* Layers of wrapping is to prevent layout thrashing on animation */}
-
+
{this.props.name}
+ {this.props.showPlayButton ? (
+
+ ) : null}
);
}
@@ -149,6 +162,7 @@ LibraryItemComponent.propTypes = {
iconURL: PropTypes.string,
insetIconURL: PropTypes.string,
internetConnectionRequired: PropTypes.bool,
+ isPlaying: PropTypes.bool,
name: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node
@@ -158,11 +172,15 @@ LibraryItemComponent.propTypes = {
onFocus: PropTypes.func.isRequired,
onKeyPress: PropTypes.func.isRequired,
onMouseEnter: PropTypes.func.isRequired,
- onMouseLeave: PropTypes.func.isRequired
+ onMouseLeave: PropTypes.func.isRequired,
+ onPlay: PropTypes.func.isRequired,
+ onStop: PropTypes.func.isRequired,
+ showPlayButton: PropTypes.bool
};
LibraryItemComponent.defaultProps = {
- disabled: false
+ disabled: false,
+ showPlayButton: false
};
export default LibraryItemComponent;
diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx
index 13736d60f..1272cb4a1 100644
--- a/src/components/library/library.jsx
+++ b/src/components/library/library.jsx
@@ -38,12 +38,13 @@ class LibraryComponent extends React.Component {
'handleFilterClear',
'handleMouseEnter',
'handleMouseLeave',
+ 'handlePlayingEnd',
'handleSelect',
'handleTagClick',
'setFilteredDataRef'
]);
this.state = {
- selectedItem: null,
+ playingItem: null,
filterQuery: '',
selectedTag: ALL_TAG.tag,
loaded: false
@@ -54,6 +55,7 @@ class LibraryComponent extends React.Component {
setTimeout(() => {
this.setState({loaded: true});
});
+ if (this.props.setStopHandler) this.props.setStopHandler(this.handlePlayingEnd);
}
componentDidUpdate (prevProps, prevState) {
if (prevState.filterQuery !== this.state.filterQuery ||
@@ -69,22 +71,58 @@ class LibraryComponent extends React.Component {
this.props.onRequestClose();
}
handleTagClick (tag) {
- this.setState({
- filterQuery: '',
- selectedTag: tag.toLowerCase()
- });
+ if (this.state.playingItem === null) {
+ this.setState({
+ filterQuery: '',
+ selectedTag: tag.toLowerCase()
+ });
+ } else {
+ this.props.onItemMouseLeave(this.getFilteredData()[[this.state.playingItem]]);
+ this.setState({
+ filterQuery: '',
+ playingItem: null,
+ selectedTag: tag.toLowerCase()
+ });
+ }
}
handleMouseEnter (id) {
- if (this.props.onItemMouseEnter) this.props.onItemMouseEnter(this.getFilteredData()[id]);
+ // don't restart if mouse over already playing item
+ if (this.props.onItemMouseEnter && this.state.playingItem !== id) {
+ this.props.onItemMouseEnter(this.getFilteredData()[id]);
+ this.setState({
+ playingItem: id
+ });
+ }
}
handleMouseLeave (id) {
- if (this.props.onItemMouseLeave) this.props.onItemMouseLeave(this.getFilteredData()[id]);
+ if (this.props.onItemMouseLeave) {
+ this.props.onItemMouseLeave(this.getFilteredData()[id]);
+ this.setState({
+ playingItem: null
+ });
+ }
+ }
+ handlePlayingEnd () {
+ if (this.state.playingItem !== null) {
+ this.setState({
+ playingItem: null
+ });
+ }
}
handleFilterChange (event) {
- this.setState({
- filterQuery: event.target.value,
- selectedTag: ALL_TAG.tag
- });
+ if (this.state.playingItem === null) {
+ this.setState({
+ filterQuery: event.target.value,
+ selectedTag: ALL_TAG.tag
+ });
+ } else {
+ this.props.onItemMouseLeave(this.getFilteredData()[[this.state.playingItem]]);
+ this.setState({
+ filterQuery: event.target.value,
+ playingItem: null,
+ selectedTag: ALL_TAG.tag
+ });
+ }
}
handleFilterClear () {
this.setState({filterQuery: ''});
@@ -185,8 +223,10 @@ class LibraryComponent extends React.Component {
id={index}
insetIconURL={dataItem.insetIconURL}
internetConnectionRequired={dataItem.internetConnectionRequired}
+ isPlaying={this.state.playingItem === index}
key={typeof dataItem.name === 'string' ? dataItem.name : dataItem.rawURL}
name={dataItem.name}
+ showPlayButton={this.props.showPlayButton}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onSelect={this.handleSelect}
@@ -227,12 +267,15 @@ LibraryComponent.propTypes = {
onItemMouseLeave: PropTypes.func,
onItemSelected: PropTypes.func,
onRequestClose: PropTypes.func,
+ setStopHandler: PropTypes.func,
+ showPlayButton: PropTypes.bool,
tags: PropTypes.arrayOf(PropTypes.shape(TagButton.propTypes)),
title: PropTypes.string.isRequired
};
LibraryComponent.defaultProps = {
- filterable: true
+ filterable: true,
+ showPlayButton: false
};
export default injectIntl(LibraryComponent);
diff --git a/src/components/play-button/icon--play.svg b/src/components/play-button/icon--play.svg
new file mode 100644
index 000000000..7c4f45df7
--- /dev/null
+++ b/src/components/play-button/icon--play.svg
@@ -0,0 +1,27 @@
+
+
diff --git a/src/components/play-button/icon--stop.svg b/src/components/play-button/icon--stop.svg
new file mode 100644
index 000000000..53837e30c
--- /dev/null
+++ b/src/components/play-button/icon--stop.svg
@@ -0,0 +1,28 @@
+
+
diff --git a/src/components/play-button/play-button.css b/src/components/play-button/play-button.css
new file mode 100644
index 000000000..17d2b6317
--- /dev/null
+++ b/src/components/play-button/play-button.css
@@ -0,0 +1,42 @@
+
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.play-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ overflow: hidden; /* Mask the icon animation */
+ width: 2.5rem;
+ height: 2.5rem;
+ background-color: $sound-primary;
+ color: $ui-white;
+ border-radius: 50%;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ user-select: none;
+ cursor: pointer;
+ transition: all 0.15s ease-out;
+}
+
+.play-button {
+ position: absolute;
+ top: .5rem;
+ z-index: auto;
+}
+
+.play-button:focus {
+ outline: none;
+}
+
+.play-icon {
+ width: 50%;
+}
+
+[dir="ltr"] .play-button {
+ right: .5rem;
+}
+
+[dir="rtl"] .play-button {
+ left: .5rem;
+}
diff --git a/src/components/play-button/play-button.jsx b/src/components/play-button/play-button.jsx
new file mode 100644
index 000000000..15f8226ac
--- /dev/null
+++ b/src/components/play-button/play-button.jsx
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+
+import {defineMessages, injectIntl, intlShape} from 'react-intl';
+
+import styles from './play-button.css';
+
+import playIcon from './icon--play.svg';
+import stopIcon from './icon--stop.svg';
+
+const messages = defineMessages({
+ play: {
+ id: 'gui.playButton.play',
+ description: 'Title of the button to start playing the sound',
+ defaultMessage: 'Play'
+ },
+ stop: {
+ id: 'gui.playButton.stop',
+ description: 'Title of the button to stop the sound',
+ defaultMessage: 'Stop'
+ }
+});
+
+const PlayButtonComponent = ({
+ className,
+ intl,
+ isPlaying,
+ onClick,
+ onMouseDown,
+ onMouseEnter,
+ onMouseLeave,
+ setButtonRef,
+ ...props
+}) => {
+ const label = isPlaying ?
+ intl.formatMessage(messages.stop) :
+ intl.formatMessage(messages.play);
+
+ return (
+
+

+
+ );
+};
+
+PlayButtonComponent.propTypes = {
+ className: PropTypes.string,
+ intl: intlShape,
+ isPlaying: PropTypes.bool.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onMouseDown: PropTypes.func.isRequired,
+ onMouseEnter: PropTypes.func.isRequired,
+ onMouseLeave: PropTypes.func.isRequired,
+ setButtonRef: PropTypes.func.isRequired
+};
+
+export default injectIntl(PlayButtonComponent);
diff --git a/src/containers/library-item.jsx b/src/containers/library-item.jsx
index 6177b916f..8881a57cc 100644
--- a/src/containers/library-item.jsx
+++ b/src/containers/library-item.jsx
@@ -15,6 +15,8 @@ class LibraryItem extends React.PureComponent {
'handleKeyPress',
'handleMouseEnter',
'handleMouseLeave',
+ 'handlePlay',
+ 'handleStop',
'rotateIcon',
'startRotatingIcons',
'stopRotatingIcons'
@@ -37,7 +39,9 @@ class LibraryItem extends React.PureComponent {
e.preventDefault();
}
handleFocus (id) {
- this.handleMouseEnter(id);
+ if (!this.props.showPlayButton) {
+ this.handleMouseEnter(id);
+ }
}
handleKeyPress (e) {
if (e.key === ' ' || e.key === 'Enter') {
@@ -46,22 +50,34 @@ class LibraryItem extends React.PureComponent {
}
}
handleMouseEnter () {
- this.props.onMouseEnter(this.props.id);
- if (this.props.icons && this.props.icons.length) {
- this.stopRotatingIcons();
- this.setState({
- isRotatingIcon: true
- }, this.startRotatingIcons);
+ // only show hover effects on the item if not showing a play button
+ if (!this.props.showPlayButton) {
+ this.props.onMouseEnter(this.props.id);
+ if (this.props.icons && this.props.icons.length) {
+ this.stopRotatingIcons();
+ this.setState({
+ isRotatingIcon: true
+ }, this.startRotatingIcons);
+ }
}
}
handleMouseLeave () {
- this.props.onMouseLeave(this.props.id);
- if (this.props.icons && this.props.icons.length) {
- this.setState({
- isRotatingIcon: false
- }, this.stopRotatingIcons);
+ // only show hover effects on the item if not showing a play button
+ if (!this.props.showPlayButton) {
+ this.props.onMouseLeave(this.props.id);
+ if (this.props.icons && this.props.icons.length) {
+ this.setState({
+ isRotatingIcon: false
+ }, this.stopRotatingIcons);
+ }
}
}
+ handlePlay () {
+ this.props.onMouseEnter(this.props.id);
+ }
+ handleStop () {
+ this.props.onMouseLeave(this.props.id);
+ }
startRotatingIcons () {
this.rotateIcon();
this.intervalId = setInterval(this.rotateIcon, 300);
@@ -104,13 +120,17 @@ class LibraryItem extends React.PureComponent {
id={this.props.id}
insetIconURL={this.props.insetIconURL}
internetConnectionRequired={this.props.internetConnectionRequired}
+ isPlaying={this.props.isPlaying}
name={this.props.name}
+ showPlayButton={this.props.showPlayButton}
onBlur={this.handleBlur}
onClick={this.handleClick}
onFocus={this.handleFocus}
onKeyPress={this.handleKeyPress}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
+ onPlay={this.handlePlay}
+ onStop={this.handleStop}
/>
);
}
@@ -137,13 +157,15 @@ LibraryItem.propTypes = {
id: PropTypes.number.isRequired,
insetIconURL: PropTypes.string,
internetConnectionRequired: PropTypes.bool,
+ isPlaying: PropTypes.bool,
name: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node
]),
onMouseEnter: PropTypes.func.isRequired,
onMouseLeave: PropTypes.func.isRequired,
- onSelect: PropTypes.func.isRequired
+ onSelect: PropTypes.func.isRequired,
+ showPlayButton: PropTypes.bool
};
export default injectIntl(LibraryItem);
diff --git a/src/containers/play-button.jsx b/src/containers/play-button.jsx
new file mode 100644
index 000000000..f0ed8b6a2
--- /dev/null
+++ b/src/containers/play-button.jsx
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import bindAll from 'lodash.bindall';
+
+import PlayButtonComponent from '../components/play-button/play-button.jsx';
+
+class PlayButton extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleClick',
+ 'handleMouseDown',
+ 'handleMouseEnter',
+ 'handleMouseLeave',
+ 'handleTouchStart',
+ 'setButtonRef'
+ ]);
+ this.state = {
+ touchStarted: false
+ };
+ }
+ getDerivedStateFromProps (props, state) {
+ // if touchStarted is true and it's not playing, the sound must have ended.
+ // reset the touchStarted state to allow the sound to be replayed
+ if (state.touchStarted && !props.isPlaying) {
+ return {
+ touchStarted: false
+ };
+ }
+ return null; // nothing changed
+ }
+ componentDidMount () {
+ // Touch start
+ this.buttonRef.addEventListener('touchstart', this.handleTouchStart);
+ }
+ componentWillUnmount () {
+ this.buttonRef.removeEventListener('touchstart', this.handleTouchStart);
+ }
+ handleClick (e) {
+ // stop the click from propagating out of the button
+ e.stopPropagation();
+ }
+ handleMouseDown (e) {
+ // prevent default (focus) on mouseDown
+ e.preventDefault();
+ if (this.props.isPlaying) {
+ // stop sound and reset touch state
+ this.props.onStop();
+ if (this.state.touchstarted) this.setState({touchStarted: false});
+ } else {
+ this.props.onPlay();
+ if (this.state.touchstarted) {
+ // started on touch, but now clicked mouse
+ this.setState({touchStarted: false});
+ }
+ }
+ }
+ handleTouchStart (e) {
+ if (this.props.isPlaying) {
+ // If playing, stop sound, and reset touch state
+ e.preventDefault();
+ this.setState({touchStarted: false});
+ this.props.onStop();
+ } else {
+ // otherwise start playing, and set touch state
+ e.preventDefault();
+ this.setState({touchStarted: true});
+ this.props.onPlay();
+ }
+ }
+ handleMouseEnter (e) {
+ // start the sound if it's not already playing
+ e.preventDefault();
+ if (!this.props.isPlaying) {
+ this.props.onPlay();
+ }
+ }
+ handleMouseLeave () {
+ // stop the sound unless it was started by touch
+ if (this.props.isPlaying && !this.state.touchstarted) {
+ this.props.onStop();
+ }
+ }
+ setButtonRef (ref) {
+ this.buttonRef = ref;
+ }
+ render () {
+ const {
+ className,
+ isPlaying,
+ onPlay, // eslint-disable-line no-unused-vars
+ onStop // eslint-disable-line no-unused-vars
+ } = this.props;
+ return (
+
+ );
+ }
+}
+
+PlayButton.propTypes = {
+ className: PropTypes.string,
+ isPlaying: PropTypes.bool.isRequired,
+ onPlay: PropTypes.func.isRequired,
+ onStop: PropTypes.func.isRequired
+};
+
+export default PlayButton;
diff --git a/src/containers/sound-library.jsx b/src/containers/sound-library.jsx
index e49f1057a..cdb5c7c6a 100644
--- a/src/containers/sound-library.jsx
+++ b/src/containers/sound-library.jsx
@@ -7,8 +7,8 @@ import AudioEngine from 'scratch-audio';
import LibraryComponent from '../components/library/library.jsx';
-import soundIcon from '../components/asset-panel/icon--sound.svg';
-import soundIconRtl from '../components/asset-panel/icon--sound-rtl.svg';
+import soundIcon from '../components/library-item/lib-icon--sound.svg';
+import soundIconRtl from '../components/library-item/lib-icon--sound-rtl.svg';
import soundLibraryContent from '../lib/libraries/sounds.json';
import soundTags from '../lib/libraries/sound-tags';
@@ -29,7 +29,9 @@ class SoundLibrary extends React.PureComponent {
bindAll(this, [
'handleItemSelected',
'handleItemMouseEnter',
- 'handleItemMouseLeave'
+ 'handleItemMouseLeave',
+ 'onStop',
+ 'setStopHandler'
]);
/**
@@ -43,6 +45,11 @@ class SoundLibrary extends React.PureComponent {
* @type {Promise}
*/
this.playingSoundPromise = null;
+
+ /**
+ * function to call when the sound ends
+ */
+ this.handleStop = null;
}
componentDidMount () {
this.audioEngine = new AudioEngine();
@@ -51,10 +58,22 @@ class SoundLibrary extends React.PureComponent {
componentWillUnmount () {
this.stopPlayingSound();
}
+ onStop () {
+ if (this.playingSoundPromise !== null) {
+ this.playingSoundPromise.then(soundPlayer => soundPlayer.removeListener('stop', this.onStop));
+ if (this.handleStop) this.handleStop();
+ }
+
+ }
+ setStopHandler (func) {
+ this.handleStop = func;
+ }
stopPlayingSound () {
// Playback is queued, playing, or has played recently and finished
// normally.
if (this.playingSoundPromise !== null) {
+ // Forcing sound to stop, so stop listening for sound ending:
+ this.playingSoundPromise.then(soundPlayer => soundPlayer.removeListener('stop', this.onStop));
// Queued playback began playing before this method.
if (this.playingSoundPromise.isPlaying) {
// Fetch the player from the promise and stop playback soon.
@@ -102,6 +121,7 @@ class SoundLibrary extends React.PureComponent {
// Play the sound. Playing the sound will always come before a
// paired stop if the sound must stop early.
soundPlayer.play();
+ soundPlayer.addListener('stop', this.onStop);
// Set that the sound is playing. This affects the type of stop
// instruction given if the sound must stop early.
if (this.playingSoundPromise !== null) {
@@ -141,8 +161,10 @@ class SoundLibrary extends React.PureComponent {
return (