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 0000000000000000000000000000000000000000..db193366a3f7c592381ca6382f49de2ba2abc5a2 Binary files /dev/null and b/src/components/library-item/lib-icon--sound-rtl.svg differ 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 0000000000000000000000000000000000000000..2ffc760131cccf23d7e0346985252c5e765ccefa Binary files /dev/null and b/src/components/library-item/lib-icon--sound.svg differ diff --git a/src/components/library-item/library-item.css b/src/components/library-item/library-item.css index 5e726a363950e9acd3237461c138f9adc700d5d3..79aebf79693435b0183591f64c6797967f1fb07d 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 5af6fb15956cac258263186d819a0d2bba3e375e..81eac4801c22b7f554c31fcebbc48b9a857c24e8 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 { ) : ( <Box className={classNames( - styles.libraryItem, - this.props.hidden ? styles.hidden : null + styles.libraryItem, { + [styles.hidden]: this.props.hidden + } )} role="button" tabIndex="0" @@ -115,12 +117,16 @@ class LibraryItemComponent extends React.PureComponent { onClick={this.props.onClick} onFocus={this.props.onFocus} onKeyPress={this.props.onKeyPress} - onMouseEnter={this.props.onMouseEnter} - onMouseLeave={this.props.onMouseLeave} + onMouseEnter={this.props.showPlayButton ? null : this.props.onMouseEnter} + onMouseLeave={this.props.showPlayButton ? null : this.props.onMouseLeave} > {/* Layers of wrapping is to prevent layout thrashing on animation */} <Box className={styles.libraryItemImageContainerWrapper}> - <Box className={styles.libraryItemImageContainer}> + <Box + className={styles.libraryItemImageContainer} + onMouseEnter={this.props.showPlayButton ? this.props.onMouseEnter : null} + onMouseLeave={this.props.showPlayButton ? this.props.onMouseLeave : null} + > <img className={styles.libraryItemImage} src={this.props.iconURL} @@ -128,6 +134,13 @@ class LibraryItemComponent extends React.PureComponent { </Box> </Box> <span className={styles.libraryItemName}>{this.props.name}</span> + {this.props.showPlayButton ? ( + <PlayButton + isPlaying={this.props.isPlaying} + onPlay={this.props.onPlay} + onStop={this.props.onStop} + /> + ) : null} </Box> ); } @@ -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 13736d60f3a32045a020929c0f95560d4c689917..1272cb4a17659f0649c5495b005c6e1b255fac36 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 0000000000000000000000000000000000000000..7c4f45df771c93f5487fb64479c751600fd001ba Binary files /dev/null and b/src/components/play-button/icon--play.svg differ diff --git a/src/components/play-button/icon--stop.svg b/src/components/play-button/icon--stop.svg new file mode 100644 index 0000000000000000000000000000000000000000..53837e30c9f0e93279855f364db975e17ae9386f Binary files /dev/null and b/src/components/play-button/icon--stop.svg differ diff --git a/src/components/play-button/play-button.css b/src/components/play-button/play-button.css new file mode 100644 index 0000000000000000000000000000000000000000..17d2b6317730319d842127b1c4a14610dc15f8c5 --- /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 0000000000000000000000000000000000000000..15f8226ac1d9c5cea3516ac48a90c3f2a653e25d --- /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 ( + <div + aria-label={label} + className={classNames(styles.playButton, className, { + [styles.playing]: isPlaying + })} + onClick={onClick} + onMouseDown={onMouseDown} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + ref={setButtonRef} + {...props} + > + <img + className={styles.playIcon} + draggable={false} + src={isPlaying ? stopIcon : playIcon} + /> + </div> + ); +}; + +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 6177b916fd69c00107decb03b1d0de13073194ae..8881a57cc0a77eedd12613469dab3a4b4621c704 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 0000000000000000000000000000000000000000..f0ed8b6a2026fdd57d11718f69001a7885e44667 --- /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 ( + <PlayButtonComponent + className={className} + isPlaying={isPlaying} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + setButtonRef={this.setButtonRef} + /> + ); + } +} + +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 e49f1057ab0194628029a02069ed758ff76bc4c6..cdb5c7c6a1f4fdaa693d4c756710e5149027128c 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<SoundPlayer>} */ 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 ( <LibraryComponent + showPlayButton data={soundLibraryThumbnailData} id="soundLibrary" + setStopHandler={this.setStopHandler} tags={soundTags} title={this.props.intl.formatMessage(messages.libraryTitle)} onItemMouseEnter={this.handleItemMouseEnter}