Skip to content

Commit a180652

Browse files
Nour Sharabashmmahalwy
Nour Sharabash
authored andcommittedMay 9, 2016
Audio segments fixed (quran#295)
* wip * wip * squash me * squash me * wip squash me * good work * now the translation shows while reading * done! except for uglifying... * got rid of the submodule dependency * made playback more smooth * also fixes the autocomplete input bug
1 parent 033e08c commit a180652

File tree

17 files changed

+679
-49
lines changed

17 files changed

+679
-49
lines changed
 

‎.gitmodules

Whitespace-only changes.

‎package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@
9999
"url-loader": "~0.5.5",
100100
"webpack": "^1.10.3",
101101
"webpack-isomorphic-tools": "^2.2.41",
102-
"winston": "^1.1.2"
102+
"winston": "^1.1.2",
103+
"sjcl": "~1.0.3",
104+
"js-yaml": "~3.6.0"
103105
},
104106
"devDependencies": {
105107
"babel-core": "^6.7.7",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default (function() {
2+
var secret;
3+
try {
4+
secret = require('../../../../.shh/audio-segments.secret.js');
5+
} catch(e) {
6+
secret = 'too-bad... :(';
7+
}
8+
return secret;
9+
}());

‎src/components/Audioplayer/Track/index.js

+14-5
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ export default class Track extends Component {
4444

4545
componentWillUpdate(nextProps) {
4646
if (this.props.file.src !== nextProps.file.src) {
47-
this.props.file.pause();
47+
if (!this.props.file.paused)
48+
this.props.file.pause();
49+
this.props.file.currentTime = 0;
4850
}
4951
}
5052

@@ -93,7 +95,9 @@ export default class Track extends Component {
9395
file.currentTime = 0; // eslint-disable-line no-param-reassign
9496
file.play();
9597
} else {
96-
file.pause();
98+
if (file.readyState >= 3 && file.paused) {
99+
file.pause();
100+
}
97101
onEnd();
98102
}
99103
};
@@ -123,7 +127,8 @@ export default class Track extends Component {
123127
}
124128

125129
onFileUnload(file) {
126-
this.props.file.pause();
130+
if (!this.props.file.paused)
131+
this.props.file.pause();
127132
[ 'loadeddata', 'timeupdate', 'ended', 'play' ].forEach((listener) => {
128133
file.removeEventListener(listener, this.state.listeners[listener]);
129134
});
@@ -154,9 +159,13 @@ export default class Track extends Component {
154159
const { isPlaying, file } = this.props;
155160

156161
if (isPlaying) {
157-
file.play();
162+
if (file.paused && file.readyState >= 3) {
163+
file.play(); // returns a promise, can do .then(() => {});
164+
}
158165
} else {
159-
file.pause();
166+
if (!file.paused && file.readyState >= 3) {
167+
file.pause();
168+
}
160169
}
161170

162171
return (

‎src/components/Audioplayer/index.js

+33-9
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import Col from 'react-bootstrap/lib/Col';
66

77
// Redux
88
import { play, pause, repeat, toggleScroll, buildOnClient } from '../../redux/modules/audioplayer';
9-
import { setCurrentAyah } from '../../redux/modules/ayahs';
9+
import { setCurrentAyah, setCurrentWord, clearCurrentWord } from '../../redux/modules/ayahs';
1010

1111
// Components
1212
import Track from './Track';
13+
import Segments from './Segments';
1314

1415
// Helpers
1516
import debug from '../../helpers/debug';
@@ -20,7 +21,9 @@ const style = require('./style.scss');
2021
@connect(
2122
state => ({
2223
files: state.audioplayer.files,
24+
segments: state.audioplayer.segments,
2325
currentAyah: state.ayahs.current,
26+
currentWord: state.ayahs.currentWord,
2427
surahId: state.audioplayer.surahId,
2528
isSupported: state.audioplayer.isSupported,
2629
isPlaying: state.audioplayer.isPlaying,
@@ -34,6 +37,8 @@ const style = require('./style.scss');
3437
repeat: bindActionCreators(repeat, dispatch),
3538
toggleScroll: bindActionCreators(toggleScroll, dispatch),
3639
setCurrentAyah: bindActionCreators(setCurrentAyah, dispatch),
40+
setCurrentWord: bindActionCreators(setCurrentWord, dispatch),
41+
clearCurrentWord: bindActionCreators(clearCurrentWord, dispatch),
3742
buildOnClient: bindActionCreators(buildOnClient, dispatch)
3843
}),
3944
(stateProps, dispatchProps, ownProps) => {
@@ -45,10 +50,12 @@ const style = require('./style.scss');
4550

4651
const files = stateProps.files[stateProps.surahId];
4752
const ayahIds = files ? Object.keys(files) : [];
53+
const segments = stateProps.segments[stateProps.surahId];
4854

4955
return {
5056
...stateProps, ...dispatchProps, ...ownProps,
5157
files,
58+
segments,
5259
ayahIds
5360
};
5461
}
@@ -59,6 +66,7 @@ export default class Audioplayer extends Component {
5966
surah: PropTypes.object.isRequired,
6067
files: PropTypes.object,
6168
currentAyah: PropTypes.string,
69+
currentWord: PropTypes.string,
6270
buildOnClient: PropTypes.func.isRequired,
6371
onLoadAyahs: PropTypes.func.isRequired,
6472
isPlaying: PropTypes.bool.isRequired,
@@ -67,6 +75,8 @@ export default class Audioplayer extends Component {
6775
shouldRepeat: PropTypes.bool.isRequired,
6876
shouldScroll: PropTypes.bool.isRequired,
6977
setCurrentAyah: PropTypes.func.isRequired,
78+
setCurrentWord: PropTypes.func.isRequired,
79+
clearCurrentWord: PropTypes.func.isRequired,
7080
play: PropTypes.func.isRequired,
7181
pause: PropTypes.func.isRequired,
7282
repeat: PropTypes.func.isRequired,
@@ -113,7 +123,7 @@ export default class Audioplayer extends Component {
113123
setCurrentAyah(prevAyah);
114124

115125
if (shouldScroll) {
116-
scroller.scrollTo('ayah:'+ ayahNum, -120); // -120 to account for the header height
126+
scroller.scrollTo('ayah:'+ ayahNum, -150);
117127
}
118128

119129
if (wasPlaying) {
@@ -148,7 +158,7 @@ export default class Audioplayer extends Component {
148158
setCurrentAyah(nextAyah);
149159

150160
if (shouldScroll) {
151-
scroller.scrollTo('ayah:'+ ayahNum);
161+
scroller.scrollTo('ayah:'+ ayahNum, -80);
152162
}
153163

154164
if (wasPlaying) {
@@ -209,7 +219,7 @@ export default class Audioplayer extends Component {
209219
debug('component:Audioplayer', 'play');
210220

211221
if (shouldScroll) {
212-
scroller.scrollTo('ayah:'+ ayahNum);
222+
scroller.scrollTo('ayah:'+ ayahNum, -150);
213223
}
214224

215225
this.props.play();
@@ -245,9 +255,9 @@ export default class Audioplayer extends Component {
245255
if (!shouldScroll) { // we use the inverse (!) here because we're toggling, so false is true
246256
const elem = document.getElementsByName('ayah:'+ ayahNum)[0];
247257
if (elem && elem.getBoundingClientRect().top < 0) { // if the ayah is above our scroll offset
248-
scroller.scrollTo('ayah:'+ ayahNum, -120);
258+
scroller.scrollTo('ayah:'+ ayahNum, -150);
249259
} else {
250-
scroller.scrollTo('ayah:'+ ayahNum);
260+
scroller.scrollTo('ayah:'+ ayahNum, -80);
251261
}
252262
}
253263

@@ -347,7 +357,11 @@ export default class Audioplayer extends Component {
347357
play, // eslint-disable-line no-shadow
348358
pause, // eslint-disable-line no-shadow
349359
files,
360+
segments,
350361
currentAyah,
362+
currentWord,
363+
setCurrentWord,
364+
clearCurrentWord,
351365
isPlaying,
352366
shouldRepeat,
353367
isSupported,
@@ -401,9 +415,19 @@ export default class Audioplayer extends Component {
401415
onPlay={play}
402416
onPause={pause}
403417
onEnd={this.onNextAyah.bind(this)}
404-
/> :
405-
null
406-
}
418+
/> : null}
419+
{isLoadedOnClient && true ?
420+
<Segments
421+
audio={files[currentAyah]}
422+
segments={segments[currentAyah]}
423+
currentAyah={currentAyah}
424+
currentWord={currentWord}
425+
setCurrentWord={setCurrentWord}
426+
clearCurrentWord={clearCurrentWord}
427+
isPlaying={isPlaying}
428+
dispatchPlay={play}
429+
dispatchPause={pause}
430+
/> : null}
407431
</div>
408432
</div>
409433
);

‎src/components/Ayah/index.js

+72-14
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,37 @@
22
import React, { Component, PropTypes } from 'react';
33
import { Link } from 'react-router';
44
import { Element } from 'react-scroll';
5+
import ReactDOM from 'react-dom'
56

67
import Copy from '../Copy';
78

89
import debug from '../../helpers/debug';
910

1011
const styles = require('./style.scss');
1112

13+
const CHAR_TYPE_WORD = 1;
14+
const CHAR_TYPE_END = 2;
15+
const CHAR_TYPE_PAUSE = 3;
16+
const CHAR_TYPE_RUB = 4;
17+
const CHAR_TYPE_SAJDAH = 5;
18+
1219
export default class Ayah extends Component {
1320
static propTypes = {
1421
isSearched: PropTypes.bool,
1522
ayah: PropTypes.object.isRequired,
16-
match: PropTypes.array
23+
match: PropTypes.array,
24+
currentWord: PropTypes.any, // gets passed in an integer, null by default
25+
showTooltipOnFocus: PropTypes.bool
1726
};
1827

1928
static defaultProps = {
29+
currentWord: null,
2030
isSearched: false,
31+
showTooltipOnFocus: true
2132
};
2233

2334
shouldComponentUpdate(nextProps) {
24-
const conditions = [this.props.ayah !== nextProps.ayah];
35+
const conditions = [this.props.ayah !== nextProps.ayah, this.props.currentWord !== nextProps.currentWord];
2536

2637
if (this.props.match) {
2738
conditions.push(this.props.match.length !== nextProps.match.length);
@@ -30,6 +41,28 @@ export default class Ayah extends Component {
3041
return conditions.some(condition => condition);
3142
}
3243

44+
componentDidUpdate(prevProps, prevState) {
45+
// This block gives focus to the active word, which is kind of useful
46+
// for tabbing around. originally, the purpose was to show the translation
47+
// on the focus event but we're disabling that by default, so we'll need
48+
// to hook in a property from audioplayer which specifies if we've toggled
49+
// the "show tooltips" option. See NOTE #1.
50+
if (this.props.currentWord != null && this.props.showTooltipOnFocus) {// || prevProps.currentWord != null) {
51+
try {
52+
const elem = ReactDOM.findDOMNode(this);
53+
const active = elem.getElementsByClassName(styles.active)[0];
54+
if (active) {
55+
const saved = active.dataset.toggle;
56+
active.dataset.toggle = ''; // unfortunately our version of bootstrap does not respect data-trigger setting, so
57+
active.focus(); // we're preventing tooltips from showing by doing this
58+
active.dataset.toggle = saved;
59+
}
60+
} catch(e) {
61+
console.info('caught in ayah',e);
62+
}
63+
}
64+
}
65+
3366
renderTranslations() {
3467
const { ayah, match } = this.props;
3568

@@ -51,33 +84,55 @@ export default class Ayah extends Component {
5184
});
5285
}
5386

87+
onWordClick(event) {
88+
if (event.target && /^token-/.test(event.target.id)) {
89+
// call onWordClick in Surah
90+
this.props.onWordClick(event.target.id.match(/\d+/g).join(':'));
91+
}
92+
}
93+
94+
onWordFocus(event) {
95+
if (event.target && /^token-/.test(event.target.id)) {
96+
// call onWordFocus in Surah
97+
this.props.onWordFocus(event.target.id.match(/\d+/g).join(':'), event.target);
98+
}
99+
}
100+
54101
renderText() {
55102
if (!this.props.ayah.words[0].code) {
56103
return;
57104
}
58105

106+
const { currentWord } = this.props;
107+
108+
let token = 0;
59109
let text = this.props.ayah.words.map(word => {
60-
let className = `${word.className} ${word.highlight ? word.highlight: null}`;
110+
let id = null;
111+
let active = word.charTypeId == CHAR_TYPE_WORD && currentWord === token ? true : false;
112+
let className = `${word.className}${word.highlight? ' '+word.highlight : ''}${active? ' '+ styles.active : ''}`;
113+
114+
let tokenId = null;
115+
if (word.charTypeId == CHAR_TYPE_WORD) {
116+
tokenId = token;
117+
id = `token-${word.ayahKey.replace(/:/, '-')}-${token++}`;
118+
} else {
119+
id = `${word.className}-${word.codeDec}`;
120+
}
61121

62122
if (word.translation) {
63123
let tooltip = word.translation;
64124

65-
if (this.props.isSearch) {
66-
return (
67-
<Link key={word.code}
68-
className={className}
69-
data-toggle="tooltip"
70-
data-placement="top" title={tooltip}
71-
to={`/search?q=${word.word.arabic}&p=1`}
72-
dangerouslySetInnerHTML={{__html: word.code}}/>
73-
);
74-
}
75-
76125
return (
77126
<b
78127
key={word.code}
128+
id={id}
129+
onClick={this.onWordClick.bind(this)}
130+
onFocus={this.onWordFocus.bind(this)}
131+
data-token-id={tokenId}
79132
className={`${className} pointer`}
80133
data-toggle="tooltip"
134+
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'
135+
tabIndex="1"
81136
data-placement="top" title={tooltip}
82137
dangerouslySetInnerHTML={{__html: word.code}}
83138
/>
@@ -86,6 +141,9 @@ export default class Ayah extends Component {
86141
else {
87142
return (
88143
<b
144+
id={id}
145+
onClick={this.onWordClick.bind(this)}
146+
data-token-id={tokenId}
89147
className={`${className} pointer`}
90148
key={word.code}
91149
dangerouslySetInnerHTML={{__html: word.code}}

‎src/components/Ayah/style.scss

+12
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@
9191

9292
b{
9393
float: right;
94+
border-color:: transparent;
95+
border-width: 0px 0px 1px 0px;
96+
border-style: solid;
97+
&.active {
98+
color: $brand-primary-darker-5;
99+
border-color: $brand-primary-darker-15;
100+
}
94101
}
95102

96103
.line{
@@ -108,13 +115,18 @@
108115
color: $brand-primary;
109116
cursor: pointer;
110117
}
118+
&:focus{
119+
color: $brand-primary-darker-10;
120+
outline: none;
121+
}
111122
}
112123

113124
@media (max-width: $screen-xs-max) {
114125
font-size: 300%;
115126
line-height: 130%;
116127
}
117128
}
129+
118130
.translation{
119131
h4{
120132
color: $light-green;

‎src/components/ReciterDropdown/index.js

+47-2
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export const slugs = [
102102
id: 7
103103
},
104104
name: {
105-
english: 'Muhammad Siddiq al-Minshawi (Mujawwad)',
105+
english: 'Mohamed Siddiq al-Minshawi (Mujawwad)',
106106
arabic: 'محمد صديق المنشاوي (مجود)'
107107
},
108108
style: {
@@ -117,7 +117,7 @@ export const slugs = [
117117
id: 7
118118
},
119119
name: {
120-
english: 'Muhammad Siddiq al-Minshawi (Murattal)',
120+
english: 'Mohamed Siddiq al-Minshawi (Murattal)',
121121
arabic: 'محمد صديق المنشاوي (مرتل)'
122122
},
123123
style: {
@@ -126,6 +126,51 @@ export const slugs = [
126126
},
127127
id: 10
128128
},
129+
{
130+
reciter: {
131+
slug: 'altablawi',
132+
id: 9
133+
},
134+
name: {
135+
english: 'Mohamed al-Tablawi',
136+
arabic: 'محمد الطبلاوي'
137+
},
138+
style: {
139+
slug: null,
140+
id: null
141+
},
142+
id: 12
143+
},
144+
{
145+
reciter: {
146+
slug: 'alhusary',
147+
id: 5
148+
},
149+
name: {
150+
english: 'Mahmoud Khalil Al-Husary',
151+
arabic: 'محمود خليل الحصري'
152+
},
153+
style: {
154+
slug: null,
155+
id: null
156+
},
157+
id: 7
158+
},
159+
{
160+
reciter: {
161+
slug: 'muallim', // i'm just making up values for slug, i dont think we need this at all
162+
id: 5
163+
},
164+
name: {
165+
english: 'Mahmoud Khalil Al-Husary (Muallim)',
166+
arabic: 'محمود خليل الحصري'
167+
},
168+
style: {
169+
slug: 'muallim',
170+
id: 3
171+
},
172+
id: 13
173+
},
129174
{
130175
reciter: {
131176
slug: 'shuraym',

‎src/config.js.nours

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const environment = {
2+
development: {
3+
isProduction: false
4+
},
5+
production: {
6+
isProduction: true
7+
}
8+
}[process.env.NODE_ENV || 'development'];
9+
10+
const title = 'Al-Qur\'an Al-Kareem - القرآن الكريم'
11+
const description = 'The Noble Quran in many languages in an easy-to-use interface.'
12+
13+
module.exports = Object.assign({
14+
host: process.env.HOST || 'localhost',
15+
port: process.env.PORT,
16+
url: process.env.CURRENT_URL,
17+
api: process.env.API_URL,
18+
app: {
19+
head: {
20+
titleTemplate: `%s - ${title}`,
21+
meta: [
22+
{charset: 'utf-8'},
23+
{'http-equiv': 'Content-Type', content: 'text/html; charset=utf-8'},
24+
{'http-equiv': 'Content-Language', content: 'EN; AR'},
25+
{name: 'description', content: description},
26+
{name: 'keywords', content: 'quran, koran, quran, قران, القرآن, قران كريم, القران الكريم, al quran, al kareem, surah yasin, surah yaseen, yasin, surah, holy, arabic, iman, islam, Allah, book, muslim'}, // eslint-disable-line max-len
27+
{name: 'Charset', content: 'UTF-8'},
28+
{name: 'Distribution', content: 'Global'},
29+
{name: 'Rating', content: 'General'},
30+
{name: 'viewport', content: 'width=device-width, user-scalable=no, initial-scale=1'},
31+
{name: 'google-site-verification', content: 'ehFz7FvmL7V9MzP40F8_kLABhCzqGzMDMrCnUP44Too'},
32+
{name: 'theme-color', content: '#004f54'},
33+
{property: 'og:site_name', content: title},
34+
{property: 'og:image', content: 'http://quran.com/images/thumbnail.png'},
35+
{property: 'og:locale', content: 'en_US'},
36+
{property: 'og:title', content: title},
37+
{property: 'og:description', content: description},
38+
{property: 'og:url', content: 'http://quran.com'},
39+
{property: 'og:type', content: 'website'},
40+
{property: 'twitter:card', content: 'summary'},
41+
{property: 'twitter:title', content: title},
42+
{property: 'twitter:description', content: description},
43+
{property: 'twitter:image', content: 'http://quran.com/images/thumbnail.png'},
44+
{property: 'twitter:image:width', content: '200'},
45+
{property: 'twitter:image:height', content: '200'}
46+
],
47+
link: [
48+
{rel: 'apple-touch-icon', href: '/images/apple-touch-icon.png'},
49+
{rel: 'apple-touch-icon-precomposed', href: '/images/apple-touch-icon-precomposed.png'}
50+
]
51+
}
52+
}
53+
}, environment);

‎src/containers/Surah/index.js

+68-2
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const style = require('./style.scss');
3333

3434
import debug from 'utils/Debug';
3535

36-
import { clearCurrent, isLoaded, load as loadAyahs, setCurrentAyah } from '../../redux/modules/ayahs';
36+
import { clearCurrent, isLoaded, load as loadAyahs, setCurrentAyah, setCurrentWord, clearCurrentWord } from '../../redux/modules/ayahs';
3737
import { isAllLoaded, loadAll, setCurrent as setCurrentSurah } from '../../redux/modules/surahs';
3838
import { setOption, toggleReadingMode } from '../../redux/modules/options';
3939

@@ -103,8 +103,12 @@ const ayahRangeSize = 30;
103103
ayahIds.last = function() {return [...this][[...this].length - 1];};
104104

105105
const isEndOfSurah = ayahIds.last() === surah.ayat;
106+
const currentWord = state.ayahs.currentWord;
107+
const isPlaying = state.audioplayer.isPlaying;
106108

107109
return {
110+
isPlaying,
111+
currentWord,
108112
surah,
109113
ayahs,
110114
isEndOfSurah,
@@ -121,6 +125,8 @@ const ayahRangeSize = 30;
121125
setOptionDispatch: setOption,
122126
toggleReadingModeDispatch: toggleReadingMode,
123127
setCurrentAyah: setCurrentAyah,
128+
setCurrentWord: setCurrentWord,
129+
clearCurrentWord: clearCurrentWord,
124130
push
125131
}
126132
)
@@ -141,6 +147,30 @@ export default class Surah extends Component {
141147
}
142148
}
143149

150+
//<<<<<<< HEAD
151+
//=======
152+
// TODO lets try this with and without this function, but shouldComponentUpdate is the additional function from audio-segments in the merge conflict
153+
/*
154+
shouldComponentUpdate(nextProps) {
155+
const sameSurahIdRouting = this.props.params.surahId === nextProps.params.surahId;
156+
const lazyLoadFinished = sameSurahIdRouting && (!this.props.isLoaded && nextProps.isLoaded);
157+
const hasReadingModeChange = this.props.options.isReadingMode !== nextProps.options.isReadingMode;
158+
const hasFontSizeChange = this.props.options.fontSize !== nextProps.options.fontSize;
159+
const hasSurahInfoChange = this.props.options.isShowingSurahInfo !== nextProps.options.isShowingSurahInfo;
160+
const hasCurrentWordChange = this.props.currentWord !== nextProps.currentWord;
161+
162+
return (
163+
!sameSurahIdRouting ||
164+
lazyLoadFinished ||
165+
hasReadingModeChange ||
166+
hasFontSizeChange ||
167+
hasSurahInfoChange ||
168+
hasCurrentWordChange
169+
);
170+
}
171+
*/
172+
173+
//>>>>>>> audio-segments
144174
componentWillUnmount() {
145175
if (__CLIENT__) {
146176
window.removeEventListener('scroll', this.handleNavbar, true);
@@ -299,12 +329,48 @@ export default class Surah extends Component {
299329
);
300330
}
301331

332+
onWordClick(id) {
333+
const { setCurrentWord, clearCurrentWord, currentWord, isPlaying } = this.props;
334+
if (id == currentWord && !isPlaying) {
335+
clearCurrentWord();
336+
} else {
337+
setCurrentWord(id);
338+
}
339+
}
340+
341+
onWordFocus(id, elem) {
342+
try {
343+
const { setCurrentWord, clearCurrentWord, currentWord, isPlaying } = this.props;
344+
if (id != currentWord && isPlaying) {
345+
setCurrentWord(id); // let tabbing around while playing trigger seek to word action
346+
}
347+
if (elem && elem.nextSibling && elem.nextSibling.classList.contains('tooltip')) { // forcefully removing tooltips
348+
elem.nextSibling.remove(); // because our version of bootstrap does not respect the data-trigger option
349+
} else {
350+
const saved = elem.dataset.toggle;
351+
elem.dataset.toggle = '';
352+
setTimeout(function() {
353+
try {
354+
elem.dataset.toggle = saved;
355+
} catch(e) {
356+
console.info('caught in timeout',e);
357+
}
358+
}, 100);
359+
}
360+
} catch(e) {
361+
console.info('caught in onWordFocus',e);
362+
}
363+
}
364+
302365
renderAyahs() {
303-
const { ayahs } = this.props;
366+
const { ayahs, currentWord } = this.props;
304367

305368
return Object.values(ayahs).map(ayah => (
306369
<Ayah
307370
ayah={ayah}
371+
currentWord={currentWord && (new RegExp('^'+ ayah.ayahKey +':')).test(currentWord)? parseInt(currentWord.match(/\d+$/)[0], 10) : null}
372+
onWordClick={this.onWordClick.bind(this)}
373+
onWordFocus={this.onWordFocus.bind(this)}
308374
key={`${ayah.surahId}-${ayah.ayahNum}-ayah`}
309375
/>
310376
));

‎src/helpers/buildAudio.js

+12-8
Original file line numberDiff line numberDiff line change
@@ -38,45 +38,49 @@ export function testIfSupported(ayah, agent) {
3838
}
3939

4040
export function buildAudioForAyah(audio, agent) {
41-
let scopedAudio;
41+
let scopedAudio = new Audio(), segments = null;
42+
43+
scopedAudio.preload = 'none';
4244

4345
const testOperaOrFirefox = __SERVER__ ?
4446
(agent.isOpera || agent.isFirefox) :
4547
(opera.test(window.navigator.userAgent) || firefox.test(window.navigator.userAgent));
4648
const testChrome = __SERVER__ ? agent.isChrome : chrome.test(window.navigator.userAgent);
4749

48-
scopedAudio = new Audio();
49-
scopedAudio.preload = 'none';
50-
5150
if (testOperaOrFirefox) {
5251
if (audio.ogg.url) {
5352
scopedAudio.src = audio.ogg.url;
53+
segments = audio.ogg.segments;
5454
}
5555
}
5656
else {
5757
if (audio.mp3.url) {
5858
scopedAudio.src = audio.mp3.url;
59+
segments = audio.mp3.segments;
5960
}
6061
else if (audio.ogg.url) {
6162
if (testChrome) {
6263
scopedAudio.src = audio.ogg.url;
64+
segments = audio.ogg.segments;
6365
}
6466
}
6567
}
6668

67-
return scopedAudio;
69+
return { audio: scopedAudio, segments };
6870
}
6971

7072
export function buildAudioFromHash(ayahsObject = {}, agent) {
71-
const filesObject = {};
73+
const audioFromHash = {files: {}, segments: {}};
7274

7375
Object.keys(ayahsObject).forEach(ayahId => {
7476
const ayah = ayahsObject[ayahId];
77+
const audioForAyah = buildAudioForAyah(ayah.audio, agent);
7578

76-
filesObject[ayahId] = buildAudioForAyah(ayah.audio, agent);
79+
audioFromHash.files[ayahId] = audioForAyah.audio;
80+
audioFromHash.segments[ayahId] = audioForAyah.segments;
7781
});
7882

79-
return filesObject;
83+
return audioFromHash;
8084
}
8185

8286

‎src/redux/modules/audioplayer.js

+22-6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const BUILD_ON_CLIENT = '@@quran/audioplayer/BUILD_ON_CLIENT';
1414

1515
const initialState = {
1616
files: {},
17+
segments: {},
1718
userAgent: null,
1819
currentFile: null,
1920
isSupported: true,
@@ -23,21 +24,27 @@ const initialState = {
2324
isLoadedOnClient: false
2425
};
2526

26-
let newFiles;
27+
let audioFromHash;
2728
let files;
29+
let segments;
2830

2931
export default function reducer(state = initialState, action = {}) {
3032
switch (action.type) {
3133
case BUILD_ON_CLIENT:
32-
newFiles = buildAudioFromHash(state.files[action.surahId], state.userAgent);
33-
files = Object.assign({}, state.files[action.surahId], newFiles);
34+
audioFromHash = buildAudioFromHash(state.files[action.surahId], state.userAgent);
35+
files = Object.assign({}, state.files[action.surahId], audioFromHash.files);
36+
segments = Object.assign({}, state.segments[action.surahId], audioFromHash.segments);
3437

3538
return {
3639
...state,
3740
isLoadedOnClient: true,
3841
files: {
3942
...state.files,
4043
[action.surahId]: files
44+
},
45+
segments: {
46+
...state.segments,
47+
[action.surahId]: segments
4148
}
4249
};
4350
case AYAHS_CLEAR_CURRENT:
@@ -46,7 +53,11 @@ export default function reducer(state = initialState, action = {}) {
4653
files: {
4754
...state.files,
4855
[action.id]: {}
49-
}
56+
},
57+
segments: {
58+
...state.segments,
59+
[action.id]: {}
60+
},
5061
};
5162
case AYAHS_LOAD:
5263
return {
@@ -73,8 +84,9 @@ export default function reducer(state = initialState, action = {}) {
7384
}
7485

7586
const incoming = action.result.entities.ayahs;
76-
newFiles = __CLIENT__ ? buildAudioFromHash(incoming, state.userAgent) : incoming;
77-
files = Object.assign({}, state.files[action.surahId], newFiles);
87+
audioFromHash = __CLIENT__ ? buildAudioFromHash(incoming, state.userAgent) : incoming;
88+
files = Object.assign({}, state.files[action.surahId], __CLIENT__ ? audioFromHash.files : audioFromHash);
89+
segments = Object.assign({}, state.segments[action.surahId], audioFromHash.segments);
7890

7991
return {
8092
...state,
@@ -85,6 +97,10 @@ export default function reducer(state = initialState, action = {}) {
8597
files: {
8698
...state.files,
8799
[action.surahId]: files
100+
},
101+
segments: {
102+
...state.segments,
103+
[action.surahId]: segments
88104
}
89105
};
90106
case SET_USER_AGENT:

‎src/redux/modules/ayahs.js

+37-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ayahsSchema } from '../schemas';
2+
23
import { arrayOf } from 'normalizr';
34

45
import { createFontFacesArray } from '../../helpers/buildFontFaces';
@@ -8,9 +9,12 @@ export const LOAD_SUCCESS = '@@quran/ayahs/LOAD_SUCCESS';
89
export const LOAD_FAIL = '@@quran/ayahs/LOAD_FAIL';
910
export const CLEAR_CURRENT = '@@quran/ayahs/CLEAR_CURRENT';
1011
export const SET_CURRENT_AYAH = '@@quran/ayahs/SET_CURRENT_AYAH';
12+
export const SET_CURRENT_WORD = '@@quran/ayahs/SET_CURRENT_WORD';
13+
export const CLEAR_CURRENT_WORD = '@@quran/ayahs/CLEAR_CURRENT_WORD';
1114

1215
const initialState = {
1316
current: null,
17+
currentWord: null,
1418
errored: false,
1519
loaded: false,
1620
loading: false,
@@ -24,12 +28,31 @@ export default function reducer(state = initialState, action = {}) {
2428
case SET_CURRENT_AYAH:
2529
return {
2630
...state,
27-
current: action.id
31+
current: action.id,
32+
currentWord: state.current == action.id ? state.currentWord : null
33+
};
34+
case SET_CURRENT_WORD:
35+
let currentAyah = state.current;
36+
if (action.id && currentAyah) {
37+
if (!(new RegExp('^'+ currentAyah +':')).test(action.id)) {
38+
currentAyah = action.id.match(/^\d+:\d+/g)[0];
39+
}
40+
}
41+
return {
42+
...state,
43+
current: currentAyah,
44+
currentWord: action.id
45+
};
46+
case CLEAR_CURRENT_WORD:
47+
return {
48+
...state,
49+
currentWord: null
2850
};
2951
case CLEAR_CURRENT:
3052
return {
3153
...state,
3254
current: null,
55+
currentWord: null,
3356
entities: {
3457
...state.entities,
3558
[action.id]: {}
@@ -97,13 +120,26 @@ export function clearCurrent(id) {
97120
};
98121
}
99122

123+
export function clearCurrentWord() {
124+
return {
125+
type: CLEAR_CURRENT_WORD
126+
};
127+
}
128+
100129
export function setCurrentAyah(id) {
101130
return {
102131
type: SET_CURRENT_AYAH,
103132
id
104133
};
105134
}
106135

136+
export function setCurrentWord(id) {
137+
return {
138+
type: SET_CURRENT_WORD,
139+
id
140+
};
141+
}
142+
107143
export function isLoaded(globalState, surahId, from, to) {
108144
return (
109145
globalState.ayahs.entities[surahId] &&

‎src/scripts/components/header/SearchInput.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ export default class SearchInput extends React.Component {
6464
e.target.style.textAlign = 'left';
6565
}
6666

67-
this.setState({ value: this.refs.search.value.trim() });
67+
if (this.input) {
68+
this.setState({ value: this.input.value.trim() });
69+
}
6870
}
6971

7072
render() {

‎src/styles/_bootstrap-config.scss

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ $proportion-factor: 0.5;
44
// Colors
55
$brand-primary: #2CA4AB;
66
$brand-primary-darker-5: darken($brand-primary, 5%);
7+
$brand-primary-darker-10: darken($brand-primary, 10%);
8+
$brand-primary-darker-15: darken($brand-primary, 15%);
9+
$brand-primary-darker-35: darken($brand-primary, 35%);
710
$light-green: $brand-primary;
811

912
$beige: #F1ECD8;

‎webpack.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ module.exports = {
115115
'process.env': {
116116
BROWSER: true,
117117
API_URL: JSON.stringify(process.env.API_URL),
118+
SEGMENTS_KEY: JSON.stringify(process.env.SEGMENTS_KEY || '¯\_(ツ)_/¯'),
118119
CURRENT_URL: JSON.stringify(process.env.CURRENT_URL)
119120
},
120121
__SERVER__: false,

0 commit comments

Comments
 (0)
Please sign in to comment.