diff --git a/package.json b/package.json index f2dabac83..46366c901 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "raven": "^0.11.0", "raw-loader": "^0.5.1", "react": "^0.14.8", - "react-bootstrap": "^0.28.4", + "react-bootstrap": "^0.29.5", "react-cookie": "^0.3.4", "react-dom": "^0.14.0", "react-helmet": "^3.1.0", diff --git a/src/components/Audioplayer/RepeatButton/index.js b/src/components/Audioplayer/RepeatButton/index.js deleted file mode 100644 index 423e0a0c5..000000000 --- a/src/components/Audioplayer/RepeatButton/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { PropTypes } from 'react'; -import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger'; -import Tooltip from 'react-bootstrap/lib/Tooltip'; - -const style = require('../style.scss'); - -const RepeatButton = ({ shouldRepeat, onRepeatToggle }) => { - const tooltip = ( - - Repeats the current ayah on end... - - ); - - return ( -
- - - - -
- ); -}; - -RepeatButton.propTypes = { - shouldRepeat: PropTypes.bool.isRequired, - onRepeatToggle: PropTypes.func.isRequired -}; - -export default RepeatButton; diff --git a/src/components/Audioplayer/RepeatButton/spec.js b/src/components/Audioplayer/RepeatButton/spec.js deleted file mode 100644 index a31ba8f56..000000000 --- a/src/components/Audioplayer/RepeatButton/spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; - -import RepeatButton from './index'; - -let makeComponent, component, onRepeatToggle; - -describe('', () => { - beforeEach(() => { - makeComponent = (shouldRepeat) => { - onRepeatToggle = sinon.stub(); - - component = mount( - - ); - } - }); - - it('should indicate that shouldRepeat', () => { - makeComponent(true); - - expect(component.find('label').first().props().className).to.contain('repeat'); - }); - - it('should not indicate that shouldRepeat', () => { - makeComponent(false); - - expect(component.find('label').first().props().className).not.to.contain('repeat'); - }); - - it('should call onRepeatToggle when clicked', () => { - component.find('label').first().simulate('click'); - - expect(onRepeatToggle).to.have.been.called; - }); -}); diff --git a/src/components/Audioplayer/RepeatDropdown/index.js b/src/components/Audioplayer/RepeatDropdown/index.js new file mode 100644 index 000000000..60fd8b52d --- /dev/null +++ b/src/components/Audioplayer/RepeatDropdown/index.js @@ -0,0 +1,239 @@ +import React, { Component, PropTypes } from 'react'; +import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger'; +import Popover from 'react-bootstrap/lib/Popover'; +import Nav from 'react-bootstrap/lib/Nav'; +import NavItem from 'react-bootstrap/lib/NavItem'; +import FormControl from 'react-bootstrap/lib/FormControl'; +import Row from 'react-bootstrap/lib/Row'; +import Col from 'react-bootstrap/lib/Col'; + +import SwitchToggle from 'components/SwitchToggle'; + +const style = require('../style.scss'); + +export default class RepeatButton extends Component { + static propTypes = { + surah: PropTypes.object.isRequired, + repeat: PropTypes.shape({ + from: PropTypes.number, + to: PropTypes.number, + times: PropTypes.number + }).isRequired, + setRepeat: PropTypes.func.isRequired, + current: PropTypes.number.isRequired + }; + + handleToggle = () => { + const { repeat, setRepeat, current } = this.props; + + if (repeat.from) { + return setRepeat({}); + } + + return setRepeat({ + from: current, + to: current + }); + } + + handleNavChange = (nav) => { + const { setRepeat, current } = this.props; + + if (nav === 1) { + // Should set single ayah + return setRepeat({ + from: current, + to: current + }); + } + + return setRepeat({ + from: current, + to: current + 3 + }); + } + + renderRangeAyahs() { + const { surah, repeat, setRepeat } = this.props; + const array = Array(surah.ayat).join().split(','); + + return ( + + From - To:
+
    +
  • + setRepeat({ + ...repeat, + from: parseInt(event.target.value, 10), + to: parseInt(event.target.value, 10) + })} + > + { + array.map((ayah, index) => ( + + )) + } + +
  • +
  • -
  • +
  • + setRepeat({ ...repeat, to: parseInt(event.target.value, 10)})} + > + { + array.map((ayah, index) => ( + + )) + } + +
  • +
+ + ); + } + + renderSingleAyah() { + const { repeat, setRepeat, surah } = this.props; + const array = Array(surah.ayat).join().split(','); + + return ( + + Ayah:
+ setRepeat({ + ...repeat, + from: parseInt(event.target.value, 10), + to: parseInt(event.target.value, 10) + })} + > + { + array.map((ayah, index) => ( + + )) + } + + + ); + } + + renderNav() { + const { repeat } = this.props; + + return ( + + + + + + ); + } + + renderOptions() { + const { repeat } = this.props; + + return ( + + {repeat.from === repeat.to ? this.renderSingleAyah() : this.renderRangeAyahs()} + + ); + } + + renderTimes() { + const { repeat, setRepeat } = this.props; + const times = Array(10).join().split(','); + + return ( + + + Times:
+ setRepeat({ + ...repeat, + times: parseInt(event.target.value, 10) + })} + > + + { + times.map((ayah, index) => ( + + )) + } + + +
+ ); + } + + render() { + const { repeat } = this.props; + + const popover = ( + + + Toggle repeat{' '} + + + + } + > + {this.renderNav()} + {this.renderOptions()} + {this.renderTimes()} + + ); + + return ( +
+ + + +
+ ); + } +} diff --git a/src/components/Audioplayer/RepeatDropdown/spec.js b/src/components/Audioplayer/RepeatDropdown/spec.js new file mode 100644 index 000000000..59ff9107a --- /dev/null +++ b/src/components/Audioplayer/RepeatDropdown/spec.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import RepeatDropdown from './index'; + +let makeComponent, component, overlay, setRepeat; +const surah = { + ayat: 10 +}; + +makeComponent = (repeat) => { + setRepeat = sinon.stub(); + + component = mount( + + ); + + overlay = mount(component.find('OverlayTrigger').first().props().overlay); +} + +describe('', () => { + it('should not be repeating', () => { + makeComponent({times: Infinity}); + + expect(component.find('i').first().props().className).not.to.contain('repeat_'); + }); + + it('should indicate repeating', () => { + makeComponent({from: 1, to: 10, times: Infinity}); + + expect(component.find('i').first().props().className).to.contain('repeat'); + }); + + describe('when single ayah', () => { + beforeEach(() => { + makeComponent({from: 3, to: 3, times: Infinity}); + }); + + it('should have a single ayah input', () => { + expect(overlay.find('FormControl').length).to.eql(2); // with the times + }); + + it('should have value of the ayah for the input', () => { + expect(overlay.find('FormControl').first().props().value).to.eql(3); + }); + }); + + describe('when range', () => { + beforeEach(() => { + makeComponent({from: 1, to: 3, times: Infinity}); + }); + + it('should have a range ayah input', () => { + expect(overlay.find('FormControl').length).to.eql(3); // with the times + }); + + it('should have value of the ayah for the input', () => { + expect(overlay.find('FormControl').first().props().value).to.eql(1); + expect(overlay.find('FormControl').at(1).props().value).to.eql(3); + }); + }); + + describe('times', () => { + it('should have Infinity count', () => { + makeComponent({from: 1, to: 3, times: Infinity}); + + expect(overlay.find('FormControl').last().props().value).to.eql(Infinity); + }); + + it('should have a count', () => { + makeComponent({from: 1, to: 3, times: 4}); + + expect(overlay.find('FormControl').last().props().value).to.eql(4); + }); + }); +}); diff --git a/src/components/Audioplayer/index.js b/src/components/Audioplayer/index.js index e62ca62d4..05382fd7e 100644 --- a/src/components/Audioplayer/index.js +++ b/src/components/Audioplayer/index.js @@ -3,22 +3,13 @@ import { connect } from 'react-redux'; import { camelize } from 'humps'; // Redux -import { - start, - stop, - next, - previous, - toggleRepeat, - toggleScroll, - buildOnClient, - update -} from '../../redux/modules/audioplayer'; +import * as AudioActions from '../../redux/modules/audioplayer'; // Components import Track from './Track'; import Segments from './Segments'; import ScrollButton from './ScrollButton'; -import RepeatButton from './RepeatButton'; +import RepeatDropdown from './RepeatDropdown'; // Helpers import debug from '../../helpers/debug'; @@ -26,34 +17,7 @@ import scroller from '../../utils/scroller'; const style = require('./style.scss'); -@connect( - (state, ownProps) => ({ - files: state.audioplayer.files[ownProps.surah.id], - segments: state.audioplayer.segments[ownProps.surah.id], - currentFile: state.audioplayer.currentFile, - currentAyah: state.audioplayer.currentAyah, - surahId: state.audioplayer.surahId, - isSupported: state.audioplayer.isSupported, - isPlaying: state.audioplayer.isPlaying, - isLoadedOnClient: state.audioplayer.isLoadedOnClient, - isLoading: state.audioplayer.isLoading, - shouldRepeat: state.audioplayer.shouldRepeat, - shouldScroll: state.audioplayer.shouldScroll, - duration: state.audioplayer.duration, - currentTime: state.audioplayer.currentTime, - }), - { - start, - stop, - next, - previous, - toggleRepeat, - toggleScroll, - buildOnClient, - update - } -) -export default class Audioplayer extends Component { +export class Audioplayer extends Component { static propTypes = { className: PropTypes.string, surah: PropTypes.object.isRequired, @@ -65,14 +29,15 @@ export default class Audioplayer extends Component { isLoadedOnClient: PropTypes.bool.isRequired, isSupported: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, - start: PropTypes.func.isRequired, - stop: PropTypes.func.isRequired, + play: PropTypes.func.isRequired, + pause: PropTypes.func.isRequired, next: PropTypes.func.isRequired, previous: PropTypes.func.isRequired, update: PropTypes.func.isRequired, - shouldRepeat: PropTypes.bool.isRequired, + repeat: PropTypes.object.isRequired, shouldScroll: PropTypes.bool.isRequired, - toggleRepeat: PropTypes.func.isRequired, + setRepeat: PropTypes.func.isRequired, + setAyah: PropTypes.func.isRequired, toggleScroll: PropTypes.func.isRequired, isPlaying: PropTypes.bool, currentTime: PropTypes.number, @@ -156,12 +121,12 @@ export default class Audioplayer extends Component { } handleAyahChange = (direction = 'next') => { - const { isPlaying, start, stop, currentAyah } = this.props; // eslint-disable-line no-shadow, max-len + const { isPlaying, play, pause, currentAyah } = this.props; // eslint-disable-line no-shadow, max-len const previouslyPlaying = isPlaying; - if (isPlaying) stop(); + if (isPlaying) pause(); - if (!this[camelize(`get_${direction}`)]()) return stop(); + if (!this[camelize(`get_${direction}`)]()) return pause(); this.props[direction](currentAyah); @@ -169,7 +134,7 @@ export default class Audioplayer extends Component { this.preloadNext(); - if (previouslyPlaying) start(); + if (previouslyPlaying) play(); return false; } @@ -182,10 +147,10 @@ export default class Audioplayer extends Component { } } - start = () => { + play = () => { this.handleScrollTo(); - this.props.start(); + this.props.play(); this.preloadNext(); } @@ -205,6 +170,65 @@ export default class Audioplayer extends Component { } } + handleRepeat = (file) => { + const { + repeat, + currentAyah, + setRepeat, // eslint-disable-line no-shadow + setAyah // eslint-disable-line no-shadow + } = this.props; + const [surah, ayah] = currentAyah.split(':').map(val => parseInt(val, 10)); + + file.pause(); + + if (repeat.from > ayah && repeat.to < ayah) { + // user selected a range where current ayah is outside + return this.handleAyahChange(); + } + + if (repeat.from === repeat.to) { + // user selected single ayah repeat + if (ayah !== repeat.from) return this.handleAyahChange(); + + if (repeat.times === 1) { + // end of times + setRepeat({}); + + return this.handleAyahChange(); + } + + setRepeat({ ...repeat, times: repeat.times - 1 }); + file.currentTime = 0; // eslint-disable-line no-param-reassign + + return file.play(); + } + + if (repeat.from !== repeat.to) { + // user selected a range + if (ayah < repeat.to) { + // still in range + return this.handleAyahChange(); + } + + if (ayah === repeat.to) { + // end of range + if (repeat.times === 1) { + // end of times + setRepeat({}); + + return this.handleAyahChange(); + } + + setRepeat({ ...repeat, times: repeat.times - 1 }); + setAyah(`${surah}:${repeat.from}`); + + return this.play(); + } + } + + return false; + } + handleScrollToggle = (event) => { event.preventDefault(); @@ -249,12 +273,10 @@ export default class Audioplayer extends Component { }); const onEnded = () => { - const { shouldRepeat } = this.props; + const { repeat } = this.props; - if (shouldRepeat) { - file.pause(); - file.currentTime = 0; // eslint-disable-line no-param-reassign - return file.play(); + if (repeat.from) { + return this.handleRepeat(file); } if (file.readyState >= 3 && file.paused) { @@ -306,7 +328,7 @@ export default class Audioplayer extends Component { } renderPlayStopButtons() { - const { isPlaying, stop } = this.props; // eslint-disable-line no-shadow + const { isPlaying, pause } = this.props; // eslint-disable-line no-shadow let icon = ; @@ -315,7 +337,7 @@ export default class Audioplayer extends Component { } return ( - + {icon} ); @@ -361,10 +383,11 @@ export default class Audioplayer extends Component { currentTime, isSupported, duration, + surah, isLoadedOnClient, - shouldRepeat, // eslint-disable-line no-shadow + repeat, // eslint-disable-line no-shadow shouldScroll, // eslint-disable-line no-shadow - toggleRepeat // eslint-disable-line no-shadow + setRepeat // eslint-disable-line no-shadow } = this.props; if (!isSupported) { @@ -399,7 +422,12 @@ export default class Audioplayer extends Component { {this.renderNextButton()}
  • - +
  • @@ -423,3 +451,21 @@ export default class Audioplayer extends Component { ); } } + +const mapStateToProps = (state, ownProps) => ({ + files: state.audioplayer.files[ownProps.surah.id], + segments: state.audioplayer.segments[ownProps.surah.id], + currentFile: state.audioplayer.currentFile, + currentAyah: state.audioplayer.currentAyah, + surahId: state.audioplayer.surahId, + isSupported: state.audioplayer.isSupported, + isPlaying: state.audioplayer.isPlaying, + isLoadedOnClient: state.audioplayer.isLoadedOnClient, + isLoading: state.audioplayer.isLoading, + repeat: state.audioplayer.repeat, + shouldScroll: state.audioplayer.shouldScroll, + duration: state.audioplayer.duration, + currentTime: state.audioplayer.currentTime, +}); + +export default connect(mapStateToProps, AudioActions)(Audioplayer); diff --git a/src/components/Audioplayer/spec.js b/src/components/Audioplayer/spec.js new file mode 100644 index 000000000..da297dfca --- /dev/null +++ b/src/components/Audioplayer/spec.js @@ -0,0 +1,136 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Audioplayer } from './index'; + +let makeComponent, component, setRepeat, setAyah, file; + +describe('', () => { + describe('#handleRepeat', () => { + describe('single ayah repeat', () => { + beforeEach(() => { + setRepeat = sinon.stub(); + setAyah = sinon.stub(); + file = { + play: sinon.stub(), + pause: sinon.stub(), + currentTime: 100 + }; + + makeComponent = (repeat) => { + component = shallow( + + ); + + component.instance().handleAyahChange = sinon.stub(); + component.instance().play = sinon.stub(); + }; + }); + + it('should not do anything with no repeat set', () => { + makeComponent({}); + component.instance().handleRepeat(file); + + expect(component.instance().handleAyahChange).to.have.been.called; + expect(component.instance().play).not.to.have.been.called; + expect(setRepeat).not.to.have.been.called; + expect(setAyah).not.to.have.been.called; + }); + + it('should not do anything when out of range', () => { + makeComponent({from: 13, to: 13, times: 10}); + component.instance().handleRepeat(file); + + expect(component.instance().handleAyahChange).to.have.been.called; + expect(setRepeat).not.to.have.been.called; + }); + + it('should repeat current ayah', () => { + makeComponent({from: 3, to: 3, times: 10}); + component.instance().handleRepeat(file); + + expect(file.pause).to.have.been.called; + expect(setRepeat).to.have.been.called; + expect(setRepeat).to.have.been.calledWith({from: 3, to: 3, times: 9}); + expect(file.currentTime).to.eql(0); + expect(file.play).to.have.been.called; + }); + + it('should not repeat when last time to repeat', () => { + makeComponent({from: 3, to: 3, times: 1}); + component.instance().handleRepeat(file); + + expect(setRepeat).to.have.been.called; + expect(setRepeat).to.have.been.calledWith({}); + expect(component.instance().handleAyahChange).to.have.been.called; + }); + }); + + describe('ayah range repeat', () => { + beforeEach(() => { + setRepeat = sinon.stub(); + setAyah = sinon.stub(); + file = { + play: sinon.stub(), + pause: sinon.stub(), + currentTime: 100 + }; + + makeComponent = (repeat) => { + component = shallow( + + ); + + component.instance().handleAyahChange = sinon.stub(); + component.instance().play = sinon.stub(); + }; + }); + + it('should not do anything when out of range', () => { + makeComponent({from: 7, to: 13, times: 10}); + component.instance().handleRepeat(file); + + expect(component.instance().handleAyahChange).to.have.been.called; + expect(setRepeat).not.to.have.been.called; + }); + + it('should play next ayah when within range', () => { + makeComponent({from: 2, to: 5, times: 10}); + component.instance().handleRepeat(file); + + expect(file.pause).to.have.been.called; + expect(setRepeat).not.to.have.been.called; + expect(component.instance().handleAyahChange).to.have.been.called; + }); + + it('should start the range from the beginning when at the end', () => { + makeComponent({from: 1, to: 3, times: 10}); + component.instance().handleRepeat(file); + + expect(file.pause).to.have.been.called; + expect(setRepeat).to.have.been.called; + expect(setRepeat).to.have.been.calledWith({from: 1, to: 3, times: 9}); + expect(setAyah).to.have.been.calledWith('2:1'); + }); + + it('should not repeat when last time to repeat when range ayah', () => { + makeComponent({from: 1, to: 3, times: 1}); + component.instance().handleRepeat(file); + + expect(setRepeat).to.have.been.called; + expect(setRepeat).to.have.been.calledWith({}); + expect(component.instance().handleAyahChange).to.have.been.called; + }); + }); + }); +}); diff --git a/src/components/Audioplayer/style.scss b/src/components/Audioplayer/style.scss index 4c30b6689..bd422bbac 100644 --- a/src/components/Audioplayer/style.scss +++ b/src/components/Audioplayer/style.scss @@ -68,9 +68,32 @@ :local .disabled{ opacity: 0.5; cursor: not-allowed !important; + pointer-events: none; } .verse{ padding: 0px; color: $olive; } + +:local .popover{ + :global(.popover-title){ + font-family: $font-montserrat; + text-transform: uppercase; + color: $cream; + padding-top: 15px; + padding-bottom: 15px; + font-size: 0.75em; + } + + :global(.popover-content){ + :global(a){ + font-size: 0.8em; + } + } + .pill{ + :global(a){ + padding: 10px 15px; + } + } +} diff --git a/src/containers/Surah/index.js b/src/containers/Surah/index.js index ee58305da..1a39dd665 100644 --- a/src/containers/Surah/index.js +++ b/src/containers/Surah/index.js @@ -460,7 +460,7 @@ class Surah extends Component { } } -const AsyncSurah = asyncConnect([ { promise: surahsConnect }, { promise: ayahsConnect } ])(Surah); +const AsyncSurah = asyncConnect([{ promise: surahsConnect }, { promise: ayahsConnect }])(Surah); function mapStateToProps(state, ownProps) { const surahId = parseInt(ownProps.params.surahId, 10); diff --git a/src/redux/modules/audioplayer.js b/src/redux/modules/audioplayer.js index 6ef7d598b..65de3feb5 100644 --- a/src/redux/modules/audioplayer.js +++ b/src/redux/modules/audioplayer.js @@ -13,12 +13,12 @@ import { const SET_USER_AGENT = '@@quran/audioplayer/SET_USER_AGENT'; const SET_CURRENT_FILE = '@@quran/audioplayer/SET_CURRENT_FILE'; const SET_CURRENT_WORD = '@@quran/audioplayer/SET_CURRENT_WORD'; -const START = '@@quran/audioplayer/START'; -const STOP = '@@quran/audioplayer/STOP'; +const PLAY = '@@quran/audioplayer/PLAY'; +const PAUSE = '@@quran/audioplayer/PAUSE'; export const NEXT = '@@quran/audioplayer/NEXT'; export const SET_AYAH = '@@quran/audioplayer/SET'; const PREVIOUS = '@@quran/audioplayer/PREVIOUS'; -const TOGGLE_REPEAT = '@@quran/audioplayer/TOGGLE_REPEAT'; +const SET_REPEAT = '@@quran/audioplayer/SET_REPEAT'; const TOGGLE_SCROLL = '@@quran/audioplayer/TOGGLE_SCROLL'; const BUILD_ON_CLIENT = '@@quran/audioplayer/BUILD_ON_CLIENT'; const UPDATE = '@@quran/audioplayer/UPDATE'; @@ -32,7 +32,11 @@ const initialState = { currentTime: 0, isSupported: true, isPlaying: false, - shouldRepeat: false, + repeat: { + from: undefined, + to: undefined, + times: Infinity + }, shouldScroll: false, isLoadedOnClient: false, isLoading: true @@ -139,14 +143,14 @@ export default function reducer(state = initialState, action = {}) { ...state, userAgent: action.userAgent }; - case START: + case PLAY: state.currentFile.play(); return { ...state, isPlaying: true }; - case STOP: + case PAUSE: state.currentFile.pause(); return { @@ -174,7 +178,6 @@ export default function reducer(state = initialState, action = {}) { } case SET_AYAH: { - const [surahId, ayahNum] = action.currentAyah.split(':'); const currentAyah = `${surahId}:${parseInt(ayahNum, 10)}`; @@ -204,10 +207,10 @@ export default function reducer(state = initialState, action = {}) { currentTime: 0 }; } - case TOGGLE_REPEAT: + case SET_REPEAT: return { ...state, - shouldRepeat: !state.shouldRepeat + repeat: action.repeat }; case TOGGLE_SCROLL: return { @@ -292,15 +295,15 @@ export function setCurrentWord(word) { }; } -export function start() { +export function play() { return { - type: START + type: PLAY }; } -export function stop() { +export function pause() { return { - type: STOP + type: PAUSE }; } @@ -325,9 +328,10 @@ export function previous(currentAyah) { }; } -export function toggleRepeat() { +export function setRepeat(repeat) { return { - type: TOGGLE_REPEAT + type: SET_REPEAT, + repeat }; }