|
| 1 | +import React, { Component, PropTypes } from 'react'; |
| 2 | +import ReactDOM from 'react-dom'; |
| 3 | +import { decrypt } from 'sjcl'; |
| 4 | + |
| 5 | +export default class Segments extends Component { |
| 6 | + static propTypes = { |
| 7 | + audio: PropTypes.object.isRequired, |
| 8 | + segments: PropTypes.string.isRequired, |
| 9 | + isPlaying: PropTypes.bool.isRequired, |
| 10 | + currentAyah: PropTypes.string.isRequired, |
| 11 | + currentWord: PropTypes.string, |
| 12 | + setCurrentWord: PropTypes.func.isRequired, |
| 13 | + clearCurrentWord: PropTypes.func.isRequired, |
| 14 | + dispatchPlay: PropTypes.func.isRequired, |
| 15 | + dispatchPause: PropTypes.func.isRequired |
| 16 | + }; |
| 17 | + |
| 18 | + state = { // initial state |
| 19 | + segments: [], |
| 20 | + listeners: {}, |
| 21 | + seekLookup: {}, |
| 22 | + timer1: null, |
| 23 | + timer2: null, |
| 24 | + token: null, |
| 25 | + currentWord: null, |
| 26 | + dispatchedPlay: false |
| 27 | + }; |
| 28 | + |
| 29 | + constructor() { |
| 30 | + super(...arguments); |
| 31 | + this.secret = process.env.SEGMENTS_KEY; |
| 32 | + } // init |
| 33 | + |
| 34 | + componentWillMount() { |
| 35 | + this.buildSegments(this.props); |
| 36 | + } // 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. |
| 37 | + |
| 38 | + componentDidMount() { |
| 39 | + this.onAudioLoad(this.props.audio); |
| 40 | + } // 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. |
| 41 | + |
| 42 | + componentWillReceiveProps(nextProps) { |
| 43 | + if (this.props.audio.src !== nextProps.audio.src) { |
| 44 | + this.onAudioUnload(this.props.audio); |
| 45 | + this.onAudioLoad(nextProps.audio); |
| 46 | + } |
| 47 | + |
| 48 | + if (this.props.segments !== nextProps.segments) { |
| 49 | + this.buildSegments(nextProps); |
| 50 | + } |
| 51 | + } // 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. |
| 52 | + |
| 53 | + shouldComponentUpdate(nextProps, nextState) { |
| 54 | + return [ |
| 55 | + this.props.audio.src !== nextProps.audio.src, |
| 56 | + this.props.segments !== nextProps.segments, |
| 57 | + this.props.isPlaying !== nextProps.isPlaying, |
| 58 | + this.props.currentWord !== nextProps.currentWord, |
| 59 | + this.props.currentAyah !== nextProps.currentAyah |
| 60 | + ].some(test => test); |
| 61 | + } |
| 62 | + // 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. |
| 63 | + // If shouldComponentUpdate returns false, then render() will be completely skipped until the next state change. |
| 64 | + // In addition, componentWillUpdate and componentDidUpdate will not be called. |
| 65 | + |
| 66 | + 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. |
| 67 | + // highlight jumps after a pause and a play but doesnt jump if seek action |
| 68 | + |
| 69 | + componentDidUpdate(prevProps, prevState) { |
| 70 | + const wordClicked = ( |
| 71 | + (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" |
| 72 | + (prevState.currentWord == null && this.state.currentWord == null && prevProps.currentWord != this.props.currentWord) |
| 73 | + ); |
| 74 | + |
| 75 | + if (wordClicked) { // seek action |
| 76 | + const segment = this.props.currentWord? this.state.seekLookup[this.props.currentWord.replace(/^.*:(\d+)$/, '$1')] : null; |
| 77 | + |
| 78 | + if (segment) { |
| 79 | + this.seekAction(segment); |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + // highlight action |
| 84 | + 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 |
| 85 | + this.highlight(this.findIndexByTime(), 0); |
| 86 | + } |
| 87 | + |
| 88 | + if (!this.props.isPlaying && (wordClicked || (this.state.currentWord == prevState.currentWord && prevProps.currentWord != this.props.currentWord))) { |
| 89 | + this.setState({ dispatchedPlay: true }); |
| 90 | + if (this.props.audio.readyState < 4) { |
| 91 | + const events = ['loadeddata', 'loaded', 'load', 'canplay', 'canplaythrough', 'loadstart']; |
| 92 | + let seekFunction = (ev) => { |
| 93 | + this.props.dispatchPlay(); |
| 94 | + events.every((evName) => { // clean (remove) audio listeners |
| 95 | + this.props.audio.removeEventListener(evName, seekFunction, false); |
| 96 | + return true; |
| 97 | + }); |
| 98 | + }; |
| 99 | + events.every((evName) => { // add audio listeners to wait for the first available opportunity to seek |
| 100 | + this.props.audio.addEventListener(evName, seekFunction, false); |
| 101 | + return true; |
| 102 | + }); |
| 103 | + } else { |
| 104 | + this.props.dispatchPlay(); |
| 105 | + } |
| 106 | + } |
| 107 | + } // Invoked immediately after the component's updates are flushed to the DOM. This method is not called for the initial render. |
| 108 | + |
| 109 | + componentWillUnmount() { |
| 110 | + this.onAudioUnload(this.props.audio); |
| 111 | + } |
| 112 | + |
| 113 | + buildSegments(props) { |
| 114 | + this.setState({ token: null }); |
| 115 | + this.state.seekLookup = {}; |
| 116 | + |
| 117 | + let segments = null; |
| 118 | + try { |
| 119 | + segments = JSON.parse(decrypt(this.secret, new Buffer(props.segments, 'base64').toString())); |
| 120 | + } catch (e) { |
| 121 | + segments = []; |
| 122 | + } |
| 123 | + |
| 124 | + this.setState({ segments }); |
| 125 | + |
| 126 | + segments.forEach((segment, index) => { |
| 127 | + const start = segment[0], duration = segment[1], token = segment[2]; |
| 128 | + if (token >= 0) { |
| 129 | + this.state.seekLookup[token] = this.state.seekLookup[token]? this.state.seekLookup[token] |
| 130 | + : { start, duration, token, index }; |
| 131 | + } |
| 132 | + }); |
| 133 | + } |
| 134 | + |
| 135 | + onAudioLoad(audio) { |
| 136 | + const play = () => {}; |
| 137 | + audio.addEventListener('play', play, false); |
| 138 | + |
| 139 | + const pause = () => { |
| 140 | + this.clearTimeouts(); |
| 141 | + }; |
| 142 | + audio.addEventListener('pause', pause, false); |
| 143 | + |
| 144 | + const timeupdate = () => {}; |
| 145 | + audio.addEventListener('timeupdate', timeupdate, false); |
| 146 | + |
| 147 | + this.setState({ |
| 148 | + listeners: { play, pause, timeupdate } |
| 149 | + }); |
| 150 | + } |
| 151 | + |
| 152 | + onAudioUnload(audio) { |
| 153 | + Object.keys(this.state.listeners).forEach((listener) => { |
| 154 | + audio.removeEventListener(listener, this.state.listeners[listener]); |
| 155 | + }); |
| 156 | + this.clearTimeouts(); |
| 157 | + } |
| 158 | + |
| 159 | + highlight(index = 0, delta = 0) { |
| 160 | + this.setState({ currentAyah: this.props.currentAyah }); |
| 161 | + const segment = this.state.segments[index]; |
| 162 | + |
| 163 | + if (!segment) { |
| 164 | + return; |
| 165 | + } |
| 166 | + |
| 167 | + let start = segment[0], duration = segment[1], token = segment[2]; |
| 168 | + let ending = start + duration; |
| 169 | + let current = this.props.audio.currentTime * 1000.0; |
| 170 | + |
| 171 | + if (token >= 0 && this.state.token !== token) { |
| 172 | + this.setToken(token); |
| 173 | + } |
| 174 | + |
| 175 | + this.state.timer1 = setTimeout(() => { |
| 176 | + const ending = start + duration; |
| 177 | + const current = this.props.audio.currentTime * 1000.0; |
| 178 | + delta = ending - current; |
| 179 | + if (delta >= 100) { // if we have a large difference then wait to unhighlight |
| 180 | + this.state.timer2 = setTimeout(() => { |
| 181 | + this.unhighlight(index); |
| 182 | + }, delta); |
| 183 | + } else { // otherwise unhighlight immediately |
| 184 | + this.unhighlight(index, delta) |
| 185 | + } |
| 186 | + }, duration + delta); |
| 187 | + } |
| 188 | + |
| 189 | + unhighlight(index, delta = 0) { |
| 190 | + const segment = this.state.segments[index]; |
| 191 | + const token = segment[2]; |
| 192 | + |
| 193 | + if (token >= 0) { |
| 194 | + this.unsetToken(); |
| 195 | + } |
| 196 | + |
| 197 | + if (this.props.isPlaying && !this.state.dispatchedPlay) { // continue highlighting to next position |
| 198 | + this.highlight(index + 1, delta); |
| 199 | + } 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 |
| 200 | + const current = this.props.audio.currentTime * 1000; |
| 201 | + const ending = segment[0] + segment[1]; |
| 202 | + const difference = parseInt(ending - current, 10); |
| 203 | + |
| 204 | + this.setState({ dispatchedPlay: false }); |
| 205 | + |
| 206 | + if (difference <= 0) { |
| 207 | + this.props.dispatchPause(); |
| 208 | + } else { |
| 209 | + setTimeout(() => { |
| 210 | + this.props.dispatchPause(); |
| 211 | + }, difference); |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + seekAction(segment) { |
| 217 | + const { audio } = this.props; |
| 218 | + |
| 219 | + this.clearTimeouts(); |
| 220 | + |
| 221 | + if (audio.readyState >= 4) { |
| 222 | + this.goTo(segment); |
| 223 | + } |
| 224 | + else { |
| 225 | + const events = ['loadeddata', 'loaded', 'load', 'canplay', 'canplaythrough', 'loadstart']; |
| 226 | + |
| 227 | + let seekFunction = (ev) => { |
| 228 | + this.goTo(segment); |
| 229 | + |
| 230 | + events.every((evName) => { // clean (remove) audio listeners |
| 231 | + audio.removeEventListener(evName, seekFunction, false); |
| 232 | + return true; |
| 233 | + }); |
| 234 | + }; |
| 235 | + |
| 236 | + events.every((evName) => { // add audio listeners to wait for the first available opportunity to seek |
| 237 | + audio.addEventListener(evName, seekFunction, false); |
| 238 | + return true; |
| 239 | + }); |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + findIndexByTime() { |
| 244 | + const { audio } = this.props; |
| 245 | + const currentTime = audio.currentTime; |
| 246 | + let index = 0; |
| 247 | + |
| 248 | + Object.values(this.state.seekLookup).every((segment) => { |
| 249 | + if (currentTime * 1000 >= segment.start - 1 && currentTime * 1000 < segment.start + segment.duration) { |
| 250 | + index = segment.index; |
| 251 | + return false; |
| 252 | + } |
| 253 | + return true; |
| 254 | + }); |
| 255 | + |
| 256 | + return index; |
| 257 | + } |
| 258 | + |
| 259 | + goTo(segment) { |
| 260 | + this.props.audio.currentTime = segment.start / 1000.0; |
| 261 | + |
| 262 | + if (this.props.isPlaying) { |
| 263 | + this.highlight(segment.index); |
| 264 | + } |
| 265 | + }; |
| 266 | + |
| 267 | + clearTimeouts() { |
| 268 | + clearTimeout(this.state.timer1); |
| 269 | + clearTimeout(this.state.timer2); |
| 270 | + } |
| 271 | + |
| 272 | + setToken(token) { |
| 273 | + const { currentAyah } = this.props; |
| 274 | + const currentWord = `${currentAyah}:${token}`; |
| 275 | + this.setState({ token, currentWord }); |
| 276 | + this.props.setCurrentWord(currentWord); |
| 277 | + } |
| 278 | + |
| 279 | + unsetToken() { |
| 280 | + this.setState({ token: null }); |
| 281 | + this.setState({ currentWord: null }); |
| 282 | + this.props.clearCurrentWord(); |
| 283 | + } |
| 284 | + |
| 285 | + render() { |
| 286 | + return ( |
| 287 | + <div></div> |
| 288 | + ); |
| 289 | + } |
| 290 | +} |
0 commit comments