diff --git a/src/components/Audioplayer/Segments/index.js b/src/components/Audioplayer/Segments/index.js index 557b284fd..b3ef9875e 100644 --- a/src/components/Audioplayer/Segments/index.js +++ b/src/components/Audioplayer/Segments/index.js @@ -5,115 +5,92 @@ import { decrypt } from 'sjcl'; export default class Segments extends Component { static propTypes = { audio: PropTypes.object.isRequired, - segments: PropTypes.string.isRequired, - isPlaying: PropTypes.bool.isRequired, - currentAyah: PropTypes.string.isRequired, - currentWord: PropTypes.string, - setCurrentWord: PropTypes.func.isRequired, - clearCurrentWord: PropTypes.func.isRequired, - dispatchPlay: PropTypes.func.isRequired, - dispatchPause: PropTypes.func.isRequired + segments: PropTypes.string.isRequired }; - state = { // initial state - segments: [], - listeners: {}, - seekLookup: {}, - timer1: null, - timer2: null, - token: null, - currentWord: null, - dispatchedPlay: false + static defaultProps = { + currentWord: null + }; + + state = { + intervals: [], }; constructor() { super(...arguments); this.secret = process.env.SEGMENTS_KEY; + this.currentWord = null; } - componentWillMount() { - this.buildSegments(this.props); - } // Invoked once, both on the client and server, immediately before the initial rendering occurs. If you call setState within this method, render() will see the updated state and will be executed only once despite the state change. + // LIFECYCLE METHODS componentDidMount() { - this.onAudioLoad(this.props.audio); - } // Invoked once, only on the client (not on the server), immediately after the initial rendering occurs. At this point in the lifecycle, you can access any refs to your children (e.g., to access the underlying DOM representation). The componentDidMount() method of child components is invoked before that of parent components. + const builtIntervals = this.buildIntervals(); + console.debug('Segments componentDidMount', this.props.audio, builtIntervals); + this.bindListeners(); + } + + componentWillUnmount() { + console.log('Segments componentWillUnmount', this.props.audio, { props: this.props, state: this.state }); + this.unbindListeners(); + } componentWillReceiveProps(nextProps) { - if (this.props.audio.src !== nextProps.audio.src) { - this.onAudioUnload(this.props.audio); - this.onAudioLoad(nextProps.audio); - } + const prevProps = this.props; - if (this.props.segments !== nextProps.segments) { - this.buildSegments(nextProps); + if (prevProps.audio != nextProps.audio) { + this.unbindListeners(prevProps); + + this.buildIntervals(nextProps); + this.bindListeners(nextProps); } - } // Invoked when a component is receiving new props. This method is not called for the initial render. Use this as an opportunity to react to a prop transition before render() is called by updating the state using this.setState(). The old props can be accessed via this.props. Calling this.setState() within this function will not trigger an additional render. + } shouldComponentUpdate(nextProps, nextState) { + const prevProps = this.props; + const prevState = this.state; return [ - this.props.audio.src !== nextProps.audio.src, - this.props.segments !== nextProps.segments, - this.props.isPlaying !== nextProps.isPlaying, - this.props.currentWord !== nextProps.currentWord, - this.props.currentAyah !== nextProps.currentAyah - ].some(test => test); + prevProps.audio != nextProps.audio, + prevProps.segments != nextProps.segments, + prevState.intervals != nextState.intervals, + prevProps.currentWord != nextProps.currentWord, + nextProps.currentWord != this.currentWord + ].some(b => b); + //return false; + // TODO: I think we can just 'return false' here since there is nothing to actually render... + // oh wait, maybe i need it so that componentDidUpdate will run..., despite render() not + // actually being needed... dunno right now } - // Invoked before rendering when new props or state are being received. This method is not called for the initial render or when forceUpdate is used. - // If shouldComponentUpdate returns false, then render() will be completely skipped until the next state change. - // In addition, componentWillUpdate and componentDidUpdate will not be called. - - componentWillUpdate(nextProps, nextState) {} // Invoked immediately before rendering when new props or state are being received. This method is not called for the initial render. Use this as an opportunity to perform preparation before an update occurs. Note: You cannot use this.setState() in this method. If you need to update state in response to a prop change, use componentWillReceiveProps instead. - // highlight jumps after a pause and a play but doesnt jump if seek action componentDidUpdate(prevProps, prevState) { - const wordClicked = ( - (prevProps.currentWord == prevState.currentWord && this.props.currentWord && this.props.currentWord != prevState.currentWord) || // the state word should be equal to the props word by this point in the lifecycle if we are using our internal function to change words, so this clause says "if we were using our internal functions to change words and somebody suddenly clicked on a different word, then seek" - (prevState.currentWord == null && this.state.currentWord == null && prevProps.currentWord != this.props.currentWord) - ); - - if (wordClicked) { // seek action - const segment = this.props.currentWord? this.state.seekLookup[this.props.currentWord.replace(/^.*:(\d+)$/, '$1')] : null; - - if (segment) { - this.seekAction(segment); + if (this.currentWord != this.props.currentWord) { // currentWord was changed by the user + if (this.props.currentWord != null) { + const wordInterval = this.state.words[this.props.currentWord.split(/:/).pop()]; + const timeToSeek = wordInterval.startTime + 0.001; // seek to the currentWord starting time and return + const isSeekable = this.props.audio.seekable && this.props.audio.seekable.length > 0; + const withinRange = !isSeekable? null : timeToSeek >= this.props.audio.seekable.start(0) && timeToSeek <= this.props.audio.seekable.end(0); + + if (isSeekable && withinRange) { // seek to it + this.props.audio.currentTime = timeToSeek; + } else { // seek to it after its ready + const seekToTime = () => { + this.props.audio.currentTime = timeToSeek; + this.props.audio.removeEventListener('canplay', seekToTime, false); + }; + this.props.audio.addEventListener('canplay', seekToTime); + } } + return this.setCurrentWord(this.props.currentWord, 'componentDidUpdate'); // but don't forget to set the change internally for next time } + } - // highlight action - if (this.props.isPlaying && (!prevProps.isPlaying || this.state.currentAyah != this.props.currentAyah || prevProps.audio.src != this.props.audio.src)) { // if we just started playing or we are transitioning ayahs - this.highlight(this.findIndexByTime(), 0); - } - - if (!this.props.isPlaying && (wordClicked || (this.state.currentWord == prevState.currentWord && prevProps.currentWord != this.props.currentWord))) { - this.setState({ dispatchedPlay: true }); - if (this.props.audio.readyState < 4) { - const events = ['loadeddata', 'loaded', 'load', 'canplay', 'canplaythrough', 'loadstart']; - let seekFunction = (ev) => { - this.props.dispatchPlay(); - events.every((evName) => { // clean (remove) audio listeners - this.props.audio.removeEventListener(evName, seekFunction, false); - return true; - }); - }; - events.every((evName) => { // add audio listeners to wait for the first available opportunity to seek - this.props.audio.addEventListener(evName, seekFunction, false); - return true; - }); - } else { - this.props.dispatchPlay(); - } - } - } // Invoked immediately after the component's updates are flushed to the DOM. This method is not called for the initial render. - - componentWillUnmount() { - this.onAudioUnload(this.props.audio); + render() { + return (); } - buildSegments(props) { - this.setState({ token: null }); - this.state.seekLookup = {}; + // END LIFECYCLE METHODS + buildIntervals(props = this.props) { let segments = null; try { segments = JSON.parse(decrypt(this.secret, new Buffer(props.segments, 'base64').toString())); @@ -121,170 +98,109 @@ export default class Segments extends Component { segments = []; } - this.setState({ segments }); - - segments.forEach((segment, index) => { - const start = segment[0], duration = segment[1], token = segment[2]; - if (token >= 0) { - this.state.seekLookup[token] = this.state.seekLookup[token]? this.state.seekLookup[token] - : { start, duration, token, index }; - } - }); - } - - onAudioLoad(audio) { - const play = () => {}; - audio.addEventListener('play', play, false); - - const pause = () => { - this.clearTimeouts(); - }; - audio.addEventListener('pause', pause, false); - - const timeupdate = () => {}; - audio.addEventListener('timeupdate', timeupdate, false); + const words = {}; + const intervals = segments.map((segment, segmentIndex) => { + const startTime = segment[0], + endTime = segment[0] + segment[1], + duration = segment[1], + wordIndex = segment[2], + mappedVal = { startTime: startTime/1000, endTime: endTime/1000, duration: duration/1000 }; - this.setState({ - listeners: { play, pause, timeupdate } - }); - } + if (wordIndex >= 0 && !words[wordIndex]) + words[wordIndex] = mappedVal; - onAudioUnload(audio) { - Object.keys(this.state.listeners).forEach((listener) => { - audio.removeEventListener(listener, this.state.listeners[listener]); + return [startTime/1000, endTime/1000, wordIndex]; }); - this.clearTimeouts(); - } - highlight(index = 0, delta = 0) { - this.setState({ currentAyah: this.props.currentAyah }); - const segment = this.state.segments[index]; - - if (!segment) { - return; - } - - let start = segment[0], duration = segment[1], token = segment[2]; - let ending = start + duration; - let current = this.props.audio.currentTime * 1000.0; - - if (token >= 0 && this.state.token !== token) { - this.setToken(token); - } - - this.state.timer1 = setTimeout(() => { - const ending = start + duration; - const current = this.props.audio.currentTime * 1000.0; - delta = ending - current; - if (delta >= 100) { // if we have a large difference then wait to unhighlight - this.state.timer2 = setTimeout(() => { - this.unhighlight(index); - }, delta); - } else { // otherwise unhighlight immediately - this.unhighlight(index, delta) - } - }, duration + delta); + this.state.intervals = intervals; + this.state.words = words; + return { intervals, words }; // for console debugging } - unhighlight(index, delta = 0) { - const segment = this.state.segments[index]; - const token = segment[2]; - - if (token >= 0) { - this.unsetToken(); - } - - if (this.props.isPlaying && !this.state.dispatchedPlay) { // continue highlighting to next position - this.highlight(index + 1, delta); - } else if (this.state.dispatchedPlay) { // we dispatched a play, so now we need to dispatch a pause in order to play only a single word - const current = this.props.audio.currentTime * 1000; - const ending = segment[0] + segment[1]; - const difference = parseInt(ending - current, 10); - - this.setState({ dispatchedPlay: false }); - - if (difference <= 0) { - this.props.dispatchPause(); - } else { - setTimeout(() => { - this.props.dispatchPause(); - }, difference); - } - } - } - - seekAction(segment) { - const { audio } = this.props; - - this.clearTimeouts(); - - if (audio.readyState >= 4) { - this.goTo(segment); - } - else { - const events = ['loadeddata', 'loaded', 'load', 'canplay', 'canplaythrough', 'loadstart']; + bindListeners(props = this.props, state = this.state) { + const audio = props.audio; + const intervals = state.intervals; + const words = state.words; + + // Play listener + const play = () => { + const listeners = {}; + let repeaterId = null; + + new Promise((done, fail) => { + console.debug('Play listener for '+ props.currentAyah +' started...'); + + const intervalFn = () => { + if (audio.seeking) return console.warn('we are seeking right now?'); + if (audio.paused || audio.ended) return console.warn('stopped by running?'); + + // Was thinking about adding some initial checks before this that could reduce + // the number of times we need to resort to a search, just in case logarithmic + // time isn't good enough + const index = this.binarySearch(intervals, audio.currentTime, this.compareFn); + const currentWord = index >= 0 && intervals[index][2] >= 0 ? + this.props.currentAyah +':'+ intervals[index][2] : null; + + if (currentWord == this.props.currentWord) return; // no work to be done + else if (currentWord == this.currentWord) return; // still no work to be done + else return this.setCurrentWord(currentWord, 'Play listener Do Stuff block'); // something changed, so we deal with it + } + + intervalFn(); + repeaterId = setInterval(intervalFn, 30); + + ['pause', 'ended'].forEach((evName) => { + listeners[evName] = done; + audio.addEventListener(evName, listeners[evName], false); + }); - let seekFunction = (ev) => { - this.goTo(segment); + ['error', 'emptied', 'abort'].forEach((evName) => { + listeners[evName] = fail; + audio.addEventListener(evName, listeners[evName], false); + }); + }).then((ev) => { + clearInterval(repeaterId); - events.every((evName) => { // clean (remove) audio listeners - audio.removeEventListener(evName, seekFunction, false); - return true; + ['pause', 'ended', 'error', 'emptied', 'abort'].forEach((evName) => { + audio.removeEventListener(evName, listeners[evName]); }); - }; - events.every((evName) => { // add audio listeners to wait for the first available opportunity to seek - audio.addEventListener(evName, seekFunction, false); - return true; + console.debug('Play listener for '+ props.currentAyah +(ev && ev.type ? ' resolved by '+ ev.type : 'stopped') +' event'); }); - } - } - - findIndexByTime() { - const { audio } = this.props; - const currentTime = audio.currentTime; - let index = 0; - - Object.values(this.state.seekLookup).every((segment) => { - if (currentTime * 1000 >= segment.start - 1 && currentTime * 1000 < segment.start + segment.duration) { - index = segment.index; - return false; - } - return true; - }); + }; + audio.addEventListener('play', play, false); - return index; + this.setState({ listeners: { play }}); } - goTo(segment) { - this.props.audio.currentTime = segment.start / 1000.0; - - if (this.props.isPlaying) { - this.highlight(segment.index); - } - }; - - clearTimeouts() { - clearTimeout(this.state.timer1); - clearTimeout(this.state.timer2); + unbindListeners(props = this.props) { + props.audio.removeEventListener('play', this.state.listeners.play); } - setToken(token) { - const { currentAyah } = this.props; - const currentWord = `${currentAyah}:${token}`; - this.setState({ token, currentWord }); - this.props.setCurrentWord(currentWord); + setCurrentWord(currentWord = null, debug = null) { + this.currentWord = currentWord; // this is more immediately available but should eventually agree with props + this.props.onSetCurrentWord(currentWord); // calls the redux dispatch function passed down from the Audioplayer + console.log('setCurrentWord', currentWord, debug ? debug : ''); } - unsetToken() { - this.setState({ token: null }); - this.setState({ currentWord: null }); - this.props.clearCurrentWord(); + compareFn(time, interval) { + if (time < interval[0]) return -1; + else if (time > interval[1]) return 1; + else if (time == interval[0]) return 0; // floor inclusive + else if (time == interval[1]) return 1; + else return 0; } - render() { - return ( -
- ); + binarySearch(ar, el, compareFn = (a, b) => (a - b)) { + var m = 0; + var n = ar.length - 1; + while (m <= n) { + var k = (n + m) >> 1; + var cmp = compareFn(el, ar[k]); + if (cmp > 0) m = k + 1; + else if (cmp < 0) n = k - 1; + else return k; + } + return -m - 1; } } diff --git a/src/components/Audioplayer/Track/index.js b/src/components/Audioplayer/Track/index.js index f81521218..f7afd5d42 100644 --- a/src/components/Audioplayer/Track/index.js +++ b/src/components/Audioplayer/Track/index.js @@ -2,18 +2,18 @@ import React, { Component, PropTypes } from 'react'; import ReactDOM from 'react-dom'; import Tracker from './Tracker'; -// import debug from '../../../../scripts/helpers/debug'; const style = require('./style.scss'); export default class Track extends Component { static propTypes = { file: PropTypes.object.isRequired, + isStarted: PropTypes.bool.isRequired, isPlaying: PropTypes.bool.isRequired, + onEnd: PropTypes.func.isRequired, shouldRepeat: PropTypes.bool.isRequired, - onPlay: PropTypes.func.isRequired, - onPause: PropTypes.func.isRequired, - onEnd: PropTypes.func.isRequired + doStop: PropTypes.func, + surah: PropTypes.object }; state = { @@ -23,96 +23,93 @@ export default class Track extends Component { }; componentDidMount() { - if (this.props.file) { + this.setState({ mounted: true }); + if (this.props.file && __CLIENT__) { + console.debug('Track componentDidMount', this.props.file, { file: this.props.file }); this.onFileLoad(this.props.file); } } componentWillUnmount() { + console.log('Track componentWillUnmount', this.props.file); + this.setState({ mounted: false }); + this.state.mounted = false; + // trace memory profile count this.onFileUnload(this.props.file); } shouldComponentUpdate(nextProps, nextState) { return [ this.props.file.src !== nextProps.file.src, + this.props.isStarted !== nextProps.isStarted, this.props.isPlaying !== nextProps.isPlaying, this.props.shouldRepeat !== nextProps.shouldRepeat, - this.state.progress !== nextState.progress, - this.state.currentTime !== nextState.currentTime + this.state.progress !== nextState.progress, // do we need this?? + this.state.currentTime !== nextState.currentTime // do we need this?? ].some(test => test); } - componentWillUpdate(nextProps) { - if (this.props.file.src !== nextProps.file.src) { - if (!this.props.file.paused) - this.props.file.pause(); - this.props.file.currentTime = 0; - } - } + componentDidUpdate(prevProps, prevState) { + if (this.props.file.src !== prevProps.file.src) { + if (!prevProps.file.paused) { + prevProps.file.pause(); + } + prevProps.file.currentTime = 0; - componentWillReceiveProps(nextProps) { - if (this.props.file.src !== nextProps.file.src) { - this.onFileUnload(this.props.file); - this.setState({ - progress: 0 - }); + this.onFileUnload(prevProps.file); + this.setState({ progress: 0 }); + this.onFileLoad(this.props.file); - this.onFileLoad(nextProps.file); } } onFileLoad(file) { - // debug('component:Track', `File loaded with src ${file.src}`); - // Preload file file.setAttribute('preload', 'auto'); const loadeddata = () => { // Default current time to zero. This will change file.currentTime = 0; // eslint-disable-line no-param-reassign - - // this.setState({isAudioLoaded: true}); }; file.addEventListener('loadeddata', loadeddata); const timeupdate = () => { + console.assert(this.state.mounted, 'timeupdate without being mounted', file, { file, mounted: this.state.mounted }); + if (!this.state.mounted) return; // TODO needed? + const progress = ( file.currentTime / file.duration * 100 ); - this.setState({ - progress - }); + this.setState({ progress }); }; file.addEventListener('timeupdate', timeupdate, false); const ended = () => { - const { shouldRepeat, onEnd } = this.props; + const { shouldRepeat, onEnd, isStarted, doStop, currentAyah, surah } = this.props; - if (shouldRepeat) { + // if we're on the last ayah, do a full stop at the playback end + if (currentAyah == surah.id +':'+ surah.ayat) + return doStop(); + + if (isStarted && shouldRepeat) { file.pause(); file.currentTime = 0; // eslint-disable-line no-param-reassign file.play(); } else { - if (file.readyState >= 3 && file.paused) { - file.pause(); - } onEnd(); } }; file.addEventListener('ended', ended, false); const play = () => { - const { progress } = this.state; + if (!this.state.mounted) return; - const currentTime = ( - progress / 100 * file.duration - ); + const { progress } = this.state; + const currentTime = progress / 100 * file.duration; - this.setState({ - currentTime - }); + this.setState({ currentTime }); }; file.addEventListener('play', play, false); @@ -127,11 +124,15 @@ export default class Track extends Component { } onFileUnload(file) { - if (!this.props.file.paused) - this.props.file.pause(); - [ 'loadeddata', 'timeupdate', 'ended', 'play' ].forEach((listener) => { - file.removeEventListener(listener, this.state.listeners[listener]); - }); + if (!file.paused) { + file.pause(); + } + + setTimeout(() => { + [ 'loadeddata', 'timeupdate', 'ended', 'play' ].forEach((listener) => { + file.removeEventListener(listener, this.state.listeners[listener]); + }); + }, 50); } onTrackerMove(event) { @@ -153,17 +154,15 @@ export default class Track extends Component { } render() { - // debug('component:Track', 'render'); - - const { progress } = this.state; + const { progress, mounted } = this.state; const { isPlaying, file } = this.props; - if (isPlaying) { - if (file.paused && file.readyState >= 3) { - file.play(); // returns a promise, can do .then(() => {}); - } - } else { - if (!file.paused && file.readyState >= 3) { + if (file.readyState >= 3) { + // the Math.round bit prevents us from trying to play again when we're effectively at the end of the audio file; this should allow shouldRepeat to work without getting overridden: + // ...but at the time I monkey-patched it, so we might be able to get rid of it since we cleaned up? Let's not for now... + if (isPlaying && file.paused && file.readyState >= 3 && Math.round(file.currentTime) != Math.round(file.duration)) { + file.play(); + } else if (!isPlaying && !file.paused) { file.pause(); } } diff --git a/src/components/Audioplayer/index.js b/src/components/Audioplayer/index.js index 7beab15b2..2cfee88cc 100644 --- a/src/components/Audioplayer/index.js +++ b/src/components/Audioplayer/index.js @@ -2,10 +2,11 @@ import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import Row from 'react-bootstrap/lib/Row'; import Col from 'react-bootstrap/lib/Col'; +import { Tooltip, OverlayTrigger } from 'react-bootstrap'; // Redux -import { play, pause, repeat, toggleScroll, buildOnClient } from '../../redux/modules/audioplayer'; -import { setCurrentAyah, setCurrentWord, clearCurrentWord } from '../../redux/modules/ayahs'; +import { start, stop, toggleRepeat, toggleScroll, buildOnClient } from '../../redux/modules/audioplayer'; +import { setCurrentAyah, setCurrentWord } from '../../redux/modules/ayahs'; // Components import Track from './Track'; @@ -25,19 +26,19 @@ const style = require('./style.scss'); currentWord: state.ayahs.currentWord, surahId: state.audioplayer.surahId, isSupported: state.audioplayer.isSupported, + isStarted: state.audioplayer.isStarted, isPlaying: state.audioplayer.isPlaying, isLoadedOnClient: state.audioplayer.isLoadedOnClient, shouldRepeat: state.audioplayer.shouldRepeat, shouldScroll: state.audioplayer.shouldScroll }), { - play, - pause, - repeat, + start, + stop, + toggleRepeat, toggleScroll, setCurrentAyah, setCurrentWord, - clearCurrentWord, buildOnClient }, (stateProps, dispatchProps, ownProps) => { @@ -63,11 +64,12 @@ export default class Audioplayer extends Component { static propTypes = { className: PropTypes.string, surah: PropTypes.object.isRequired, + onLoadAyahs: PropTypes.func.isRequired, + files: PropTypes.object, currentAyah: PropTypes.string, currentWord: PropTypes.string, buildOnClient: PropTypes.func.isRequired, - onLoadAyahs: PropTypes.func.isRequired, isPlaying: PropTypes.bool.isRequired, isLoadedOnClient: PropTypes.bool.isRequired, isSupported: PropTypes.bool.isRequired, @@ -75,10 +77,9 @@ export default class Audioplayer extends Component { shouldScroll: PropTypes.bool.isRequired, setCurrentAyah: PropTypes.func.isRequired, setCurrentWord: PropTypes.func.isRequired, - clearCurrentWord: PropTypes.func.isRequired, - play: PropTypes.func.isRequired, - pause: PropTypes.func.isRequired, - repeat: PropTypes.func.isRequired, + start: PropTypes.func.isRequired, + stop: PropTypes.func.isRequired, + toggleRepeat: PropTypes.func.isRequired, toggleScroll: PropTypes.func.isRequired, ayahIds: PropTypes.array }; @@ -98,26 +99,24 @@ export default class Audioplayer extends Component { debug('component:Audioplayer', 'componentDidMount'); if (!isLoadedOnClient && __CLIENT__) { + console.debug('Audioplayer componentDidMount'); debug('component:Audioplayer', 'componentDidMount on client'); return buildOnClient(surah.id); - } + } else console.debug('Audioplayer componentDidMount', { notLoadedOnClient: !isLoadedOnClient, client: __CLIENT__ }); } componentWillUnmount() { debug('component:Audioplayer', 'componentWillUnmount'); - this.props.pause(); - // this.props.currentAudio.src = null; + console.log('Audioplayer componentWillUnmount'); + this.stop(); } onPreviousAyah() { - const { play, pause, setCurrentAyah, isPlaying, shouldScroll } = this.props; // eslint-disable-line no-shadow + const { setCurrentAyah, isStarted, shouldScroll } = this.props; // eslint-disable-line no-shadow const prevAyah = this.getPrevious(); if (prevAyah) { const ayahNum = prevAyah.replace( /^\d+:/, '' ); - const wasPlaying = isPlaying; - - pause(); setCurrentAyah(prevAyah); @@ -125,9 +124,8 @@ export default class Audioplayer extends Component { scroller.scrollTo('ayah:'+ ayahNum, -150); } - if (wasPlaying) { - play(); - } + if (isStarted) + this.props.files[prevAyah].play(); } } @@ -135,7 +133,6 @@ export default class Audioplayer extends Component { const node = document.getElementsByName(name)[0]; if (!node) { - console.warn(`node [name=${name}] not found, could not scroll`); return; } @@ -147,23 +144,19 @@ export default class Audioplayer extends Component { } onNextAyah() { - const { play, pause, setCurrentAyah, isPlaying, shouldScroll } = this.props; // eslint-disable-line no-shadow - const wasPlaying = isPlaying; + const { setCurrentAyah, isStarted, shouldScroll } = this.props; // eslint-disable-line no-shadow const nextAyah = this.getNext(); + if (!nextAyah) return this.stop(); const ayahNum = nextAyah.replace( /^\d+:/, '' ); - pause(); - setCurrentAyah(nextAyah); if (shouldScroll) { scroller.scrollTo('ayah:'+ ayahNum, -80); } - if (wasPlaying) { - play(); - this.preloadNext(); - } + this.preloadNext(); + if (isStarted) this.props.files[nextAyah].play(); } getCurrent() { @@ -179,6 +172,7 @@ export default class Audioplayer extends Component { // the previous button const { currentAyah, ayahIds } = this.props; const index = ayahIds.findIndex(id => id === currentAyah) - 1; + console.debug('getPrevious', { props: this.props, index, prevAyah: ayahIds[index], currentAyah }) return ayahIds[index]; } @@ -187,44 +181,39 @@ export default class Audioplayer extends Component { const index = ayahIds.findIndex(id => id === currentAyah) + 1; if ((ayahIds.length - 3) <= index) { - onLoadAyahs(); + onLoadAyahs(); // this doesnt look right, should probably be returned or promise.then? } + console.debug('getNext', { props: this.props, index, nextAyah: ayahIds[index], currentAyah }) return ayahIds[index]; } startStopPlayer(event) { - const { isPlaying } = this.props; - event.preventDefault(); + const { isStarted } = this.props; - if (isPlaying) { - return this.pause(); - } - - return this.play(); + if (isStarted) + return this.stop(); + return this.start(); } - pause() { - debug('component:Audioplayer', 'pause'); - this.props.pause(); - } - - play() { + start() { const { shouldScroll, files } = this.props; const currentAyah = this.getCurrent(); const ayahNum = currentAyah.replace( /^\d+:/, '' ); - debug('component:Audioplayer', 'play'); - if (shouldScroll) { scroller.scrollTo('ayah:'+ ayahNum, -150); } - this.props.play(); + this.props.start(); this.preloadNext(); } + stop() { + this.props.stop(); + } + preloadNext() { const { currentAyah, ayahIds, files } = this.props; const index = ayahIds.findIndex(id => id === currentAyah) + 1; @@ -238,10 +227,10 @@ export default class Audioplayer extends Component { } } - repeat(event) { + toggleRepeat(event) { event.preventDefault(); - this.props.repeat(); + this.props.toggleRepeat(); } toggleScroll(event) { @@ -260,7 +249,6 @@ export default class Audioplayer extends Component { } } - this.props.toggleScroll(); } @@ -279,11 +267,11 @@ export default class Audioplayer extends Component { } renderPlayStopButtons() { - const { isPlaying } = this.props; + const { isStarted } = this.props; let icon = ; - if (isPlaying) { + if (isStarted) { icon = ; } @@ -315,34 +303,46 @@ export default class Audioplayer extends Component { renderRepeatButton() { const { shouldRepeat } = this.props; + const tooltip = (Repeats the current ayah on end...); return ( + + ); } renderScrollButton() { const { shouldScroll } = this.props; + const tooltip = (Automatically scrolls to the currently playing ayah on transitions...); return ( + + ); } @@ -353,18 +353,17 @@ export default class Audioplayer extends Component { const { className, - play, // eslint-disable-line no-shadow - pause, // eslint-disable-line no-shadow files, segments, currentAyah, currentWord, setCurrentWord, - clearCurrentWord, isPlaying, + isStarted, shouldRepeat, isSupported, - isLoadedOnClient + isLoadedOnClient, + surah } = this.props; // eslint-disable-line no-shadow if (!isSupported) { @@ -408,12 +407,15 @@ export default class Audioplayer extends Component {
{isLoadedOnClient ? : null} {isLoadedOnClient && segments[currentAyah] ? : null}
diff --git a/src/components/Audioplayer/style.scss b/src/components/Audioplayer/style.scss index 2afd0e0c1..46a205b89 100644 --- a/src/components/Audioplayer/style.scss +++ b/src/components/Audioplayer/style.scss @@ -35,6 +35,8 @@ .buttons{ + width: 100%; + display: inline-block; cursor: pointer; padding-right: 1.5%; color: $olive; diff --git a/src/components/Ayah/index.js b/src/components/Ayah/index.js index c1564078e..72f9652d0 100644 --- a/src/components/Ayah/index.js +++ b/src/components/Ayah/index.js @@ -22,13 +22,11 @@ export default class Ayah extends Component { ayah: PropTypes.object.isRequired, match: PropTypes.array, currentWord: PropTypes.any, // gets passed in an integer, null by default - showTooltipOnFocus: PropTypes.bool }; static defaultProps = { currentWord: null, isSearched: false, - showTooltipOnFocus: true }; shouldComponentUpdate(nextProps) { @@ -41,28 +39,6 @@ export default class Ayah extends Component { return conditions.some(condition => condition); } - componentDidUpdate(prevProps, prevState) { - // This block gives focus to the active word, which is kind of useful - // for tabbing around. originally, the purpose was to show the translation - // on the focus event but we're disabling that by default, so we'll need - // to hook in a property from audioplayer which specifies if we've toggled - // the "show tooltips" option. See NOTE #1. - if (this.props.currentWord != null && this.props.showTooltipOnFocus) {// || prevProps.currentWord != null) { - try { - const elem = ReactDOM.findDOMNode(this); - const active = elem.getElementsByClassName(styles.active)[0]; - if (active) { - const saved = active.dataset.toggle; - active.dataset.toggle = ''; // unfortunately our version of bootstrap does not respect data-trigger setting, so - active.focus(); // we're preventing tooltips from showing by doing this - active.dataset.toggle = saved; - } - } catch(e) { - console.info('caught in ayah',e); - } - } - } - renderTranslations() { const { ayah, match } = this.props; @@ -85,14 +61,14 @@ export default class Ayah extends Component { } onWordClick(event) { - if (event.target && /^token-/.test(event.target.id)) { + if (event.target && /^word-/.test(event.target.id)) { // call onWordClick in Surah this.props.onWordClick(event.target.id.match(/\d+/g).join(':')); } } onWordFocus(event) { - if (event.target && /^token-/.test(event.target.id)) { + if (event.target && /^word-/.test(event.target.id)) { // call onWordFocus in Surah this.props.onWordFocus(event.target.id.match(/\d+/g).join(':'), event.target); } @@ -102,21 +78,18 @@ export default class Ayah extends Component { if (!this.props.ayah.words[0].code) { return; } - const { currentWord } = this.props; - let token = 0; + let position = 0; let text = this.props.ayah.words.map(word => { let id = null; - let active = word.charTypeId == CHAR_TYPE_WORD && currentWord === token ? true : false; + let active = word.charTypeId == CHAR_TYPE_WORD && currentWord === position ? true : false; let className = `${word.className}${word.highlight? ' '+word.highlight : ''}${active? ' '+ styles.active : ''}`; - let tokenId = null; if (word.charTypeId == CHAR_TYPE_WORD) { - tokenId = token; - id = `token-${word.ayahKey.replace(/:/, '-')}-${token++}`; + id = `word-${word.ayahKey.replace(/:/, '-')}-${position++}`; } else { - id = `${word.className}-${word.codeDec}`; + id = `${word.className}-${word.codeDec}`; // just don't include id } if (word.translation) { @@ -128,10 +101,9 @@ export default class Ayah extends Component { id={id} onClick={this.onWordClick.bind(this)} onFocus={this.onWordFocus.bind(this)} - data-token-id={tokenId} className={`${className} pointer`} data-toggle="tooltip" - data-trigger="hover" // NOTE #1: if we want to use the focus event to do something like show a translation in the future, then change this to 'hover,focus' + data-trigger="hover" // NOTE #1: if we want to use the focus event to do something like show a translation in tabIndex="1" data-placement="top" title={tooltip} dangerouslySetInnerHTML={{__html: word.code}} @@ -143,7 +115,6 @@ export default class Ayah extends Component { condition); + } + // If shouldComponentUpdate returns false, then __render() will be completely skipped__ until the next state change. + // In addition, __componentWillUpdate and componentDidUpdate will not be called__. + constructor() { super(...arguments); } @@ -250,7 +272,7 @@ export default class Surah extends Component { lazyLoadAyahs(callback) { - const { loadAyahsDispatch, ayahIds, surah, options } = this.props; + const { loadAyahsDispatch, ayahIds, surah, isEndOfSurah, options } = this.props; const range = [ayahIds.first(), ayahIds.last()]; let size = 10; @@ -262,7 +284,7 @@ export default class Surah extends Component { const from = range[1]; const to = (from + size); - if (!ayahIds.has(to)) { + if (!isEndOfSurah && !ayahIds.has(to)) { loadAyahsDispatch(surah.id, from, to, options).then(() => { this.setState({lazyLoading: false}); if (callback) { @@ -311,8 +333,8 @@ export default class Surah extends Component { } onWordClick(id) { - const { setCurrentWord, clearCurrentWord, currentWord, isPlaying } = this.props; - if (id == currentWord && !isPlaying) { + const { setCurrentWord, clearCurrentWord, currentWord, isStarted } = this.props; + if (id == currentWord && !isStarted) { clearCurrentWord(); } else { setCurrentWord(id); @@ -320,26 +342,9 @@ export default class Surah extends Component { } onWordFocus(id, elem) { - try { - const { setCurrentWord, clearCurrentWord, currentWord, isPlaying } = this.props; - if (id != currentWord && isPlaying) { - setCurrentWord(id); // let tabbing around while playing trigger seek to word action - } - if (elem && elem.nextSibling && elem.nextSibling.classList.contains('tooltip')) { // forcefully removing tooltips - elem.nextSibling.remove(); // because our version of bootstrap does not respect the data-trigger option - } else { - const saved = elem.dataset.toggle; - elem.dataset.toggle = ''; - setTimeout(function() { - try { - elem.dataset.toggle = saved; - } catch(e) { - console.info('caught in timeout',e); - } - }, 100); - } - } catch(e) { - console.info('caught in onWordFocus',e); + const { setCurrentWord, clearCurrentWord, currentWord, isStarted } = this.props; + if (id != currentWord && isStarted) { + setCurrentWord(id); // let tabbing around while playing trigger seek to word action } } diff --git a/src/helpers/buildAudio.js b/src/helpers/buildAudio.js index 57add41a3..9bff843d9 100644 --- a/src/helpers/buildAudio.js +++ b/src/helpers/buildAudio.js @@ -16,15 +16,15 @@ export function testIfSupported(ayah, agent) { } if (testOperaOrFirefox) { - if (!audio.ogg.url) { + if (!(audio.ogg && audio.ogg.url)) { return false; } } else { - if (audio.mp3.url) { + if (audio.mp3 && audio.mp3.url) { return true; } - else if (audio.ogg.url) { + else if (audio.ogg && audio.ogg.url) { if (!testChrome) { return false; } @@ -48,17 +48,17 @@ export function buildAudioForAyah(audio, agent) { const testChrome = __SERVER__ ? agent.isChrome : chrome.test(window.navigator.userAgent); if (testOperaOrFirefox) { - if (audio.ogg.url) { + if (audio.ogg && audio.ogg.url) { scopedAudio.src = audio.ogg.url; - segments = audio.ogg.segments; + segments = audio.ogg.encryptedSegments; } } else { - if (audio.mp3.url) { + if (audio.mp3 && audio.mp3.url) { scopedAudio.src = audio.mp3.url; segments = audio.mp3.encryptedSegments; } - else if (audio.ogg.url) { + else if (audio.ogg && audio.ogg.url) { if (testChrome) { scopedAudio.src = audio.ogg.url; segments = audio.ogg.encryptedSegments; diff --git a/src/redux/modules/audioplayer.js b/src/redux/modules/audioplayer.js index ea9db783f..b30cbab20 100644 --- a/src/redux/modules/audioplayer.js +++ b/src/redux/modules/audioplayer.js @@ -5,10 +5,12 @@ import { LOAD_SUCCESS as AYAHS_LOAD_SUCCESS, LOAD as AYAHS_LOAD, CLEAR_CURRENT a const SET_USER_AGENT = '@@quran/audioplayer/SET_USER_AGENT'; const SET_CURRENT_FILE = '@@quran/audioplayer/SET_CURRENT_FILE'; +const START = '@@quran/audioplayer/START'; +const STOP = '@@quran/audioplayer/STOP'; const PLAY = '@@quran/audioplayer/PLAY'; const PAUSE = '@@quran/audioplayer/PAUSE'; const PLAY_PAUSE = '@@quran/audioplayer/PLAY_PAUSE'; -const REPEAT = '@@quran/audioplayer/REPEAT'; +const TOGGLE_REPEAT = '@@quran/audioplayer/TOGGLE_REPEAT'; const TOGGLE_SCROLL = '@@quran/audioplayer/TOGGLE_SCROLL'; const BUILD_ON_CLIENT = '@@quran/audioplayer/BUILD_ON_CLIENT'; @@ -19,8 +21,9 @@ const initialState = { currentFile: null, isSupported: true, isPlaying: false, + isStarted: false, // like isPlaying, but doesn't toggle off everytime we have a brief pause between ayah transition shouldRepeat: false, - shouldScroll: true, + shouldScroll: false, isLoadedOnClient: false }; @@ -108,22 +111,39 @@ export default function reducer(state = initialState, action = {}) { ...state, userAgent: action.userAgent }; + case START: + console.debug('START'); + return { + ...state, + isStarted: true, + isPlaying: true + }; + case STOP: + console.debug('STOP'); + return { + ...state, + isStarted: false, + isPlaying: false + }; case PLAY: + console.debug('DISPATCH PLAY'); return { ...state, isPlaying: true }; case PAUSE: + console.debug('DISPATCH PAUSE'); return { ...state, isPlaying: false }; case PLAY_PAUSE: + console.debug('PLAY_PAUSE'); return { ...state, isPlaying: !state.isPlaying }; - case REPEAT: + case TOGGLE_REPEAT: return { ...state, shouldRepeat: !state.shouldRepeat @@ -162,6 +182,18 @@ export function setCurrentFile(file) { }; } +export function start() { + return { + type: START + }; +} + +export function stop() { + return { + type: STOP + }; +} + export function play() { return { type: PLAY @@ -180,9 +212,9 @@ export function playPause() { }; } -export function repeat() { +export function toggleRepeat() { return { - type: REPEAT + type: TOGGLE_REPEAT }; }