Skip to content

Commit 46efd5e

Browse files
authored
add seperate route for fetching the letter segments (#725)
1 parent d36a6e0 commit 46efd5e

7 files changed

Lines changed: 186 additions & 32 deletions

File tree

app/controllers/surah_audio_files_controller.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ def segments
4242
.order('verses.id ASC')
4343
end
4444

45+
def letter_segments
46+
@audio_file = load_audio_file
47+
return render(json: { letter_segments: {} }) if @audio_file.nil?
48+
49+
from = params[:from].to_i
50+
to = params[:to].to_i
51+
keys = (from..to).map { |verse_number| "#{@audio_file.chapter_id}:#{verse_number}" }
52+
53+
letters = @audio_file.audio_segments
54+
.where(verse_key: keys)
55+
.each_with_object({}) do |segment, mapping|
56+
mapping[segment.verse_key] = segment.letter_segments || []
57+
end
58+
59+
render json: { letter_segments: letters }
60+
end
61+
4562
def validate_segments
4663
@audio_file = load_audio_file
4764
return render(json: { issues: [] }) if @audio_file.nil?

app/javascript/segments/App.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,20 @@
1212
<SelectAudioSrc />
1313
<ActionBar />
1414
<Verse />
15+
16+
<div
17+
v-if="isLoading"
18+
class="fixed bottom-4 right-4 z-[200] flex items-center gap-2 px-4 py-2 bg-gray-900/90 text-white text-sm rounded-full shadow-lg pointer-events-none"
19+
>
20+
<span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
21+
{{ loadingText }}
22+
</div>
1523
</div>
1624
</template>
1725

1826
<script>
1927
// copied from https://github.com/vuejs/vuex/blob/4.0/examples/composition/shopping-cart/components/ProductList.vue
28+
import { mapState } from "vuex";
2029
import { useStore } from "vuex";
2130
import SelectAudioSrc from "./components/SelectAudioSrc.vue";
2231
import Alert from "./components/Alert.vue";
@@ -30,6 +39,15 @@ export default {
3039
showMobileWarning: true,
3140
};
3241
},
42+
computed: {
43+
...mapState(["loadingSegments", "loadingLetters"]),
44+
isLoading() {
45+
return this.loadingSegments || this.loadingLetters;
46+
},
47+
loadingText() {
48+
return this.loadingSegments ? "Loading segments…" : "Loading letters…";
49+
},
50+
},
3351
mounted() {
3452
const {
3553
recitation,

app/javascript/segments/components/Verse.vue

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -581,11 +581,18 @@ export default {
581581
this.unwatchLetters = this.$store.watch(
582582
(state) => state.showLetters,
583583
(enabled) => {
584-
if (enabled) this.startLetterTick();
585-
else this.stopLetterTick();
584+
if (enabled) {
585+
this.ensureLetterBatches();
586+
this.startLetterTick();
587+
} else {
588+
this.stopLetterTick();
589+
}
586590
}
587591
);
588-
if (this.$store.state.showLetters) this.startLetterTick();
592+
if (this.$store.state.showLetters) {
593+
this.ensureLetterBatches();
594+
this.startLetterTick();
595+
}
589596
590597
this.unwatchWord = this.$store.watch(
591598
(state, getters) => state.wordLoopTime,
@@ -622,6 +629,10 @@ export default {
622629
if (this.unwatchLetters) this.unwatchLetters();
623630
},
624631
methods: {
632+
ensureLetterBatches() {
633+
if (this.audioType === 'ayah') return;
634+
this.$store.dispatch('ENSURE_LETTER_BATCHES', { verse: Number(this.currentVerseNumber) });
635+
},
625636
hasWaqaf(segment) {
626637
return segment[3] && segment[3].waqaf === true;
627638
},
@@ -685,19 +696,27 @@ export default {
685696
// window, so timeupdate can't resolve which letter is playing. Only the
686697
// current letter is coloured; the key changes a few times a second, so the
687698
// reactive binding is cheap.
688-
if (typeof player !== 'undefined' && player && this.showLetters) {
699+
if (typeof player !== 'undefined' && player) {
689700
const time = player.currentTime * 1000;
690-
const letters = this.flatLetters;
691701
692-
let key = null;
693-
for (let i = 0; i < letters.length; i++) {
694-
if (time >= letters[i].start && time < letters[i].end) {
695-
key = letters[i].key;
696-
break;
702+
// Stop word/range playback here, on the frame-accurate clock, rather
703+
// than leaving it to the coarse `timeupdate` event — which arrives late
704+
// enough while this loop runs to let playback overrun by seconds.
705+
this.$store.commit('STOP_AT_PLAYBACK_BOUNDARY', { time });
706+
707+
if (this.showLetters) {
708+
const letters = this.flatLetters;
709+
710+
let key = null;
711+
for (let i = 0; i < letters.length; i++) {
712+
if (time >= letters[i].start && time < letters[i].end) {
713+
key = letters[i].key;
714+
break;
715+
}
697716
}
698-
}
699717
700-
if (key !== this.activeLetterKey) this.activeLetterKey = key;
718+
if (key !== this.activeLetterKey) this.activeLetterKey = key;
719+
}
701720
}
702721
703722
this._letterRaf = requestAnimationFrame(this.letterTick);

app/javascript/segments/store/index.js

Lines changed: 118 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ const COMPARE_COLORS = ["#2563eb", "#dc2626", "#9333ea", "#ea580c", "#0d9488", "
1717

1818
const localStore = new LocalStore();
1919

20+
const LETTER_BATCH_SIZE = 10;
21+
const LETTER_PREFETCH_LOOKAHEAD = 2;
22+
23+
function letterBatchStart(verse) {
24+
return Math.floor((verse - 1) / LETTER_BATCH_SIZE) * LETTER_BATCH_SIZE + 1;
25+
}
26+
27+
// In-flight letter-batch requests; loadingLetters stays on until all resolve so
28+
// the indicator does not flicker off while a concurrent batch is still loading.
29+
let pendingLetterRequests = 0;
30+
31+
function clampVerse(verse, versesCount) {
32+
const number = Number(verse) || 1;
33+
if (number < 1) return 1;
34+
if (versesCount && number > versesCount) return versesCount;
35+
return number;
36+
}
37+
2038
// Insert placeholders for missing words in sequence, keeping repeated runs intact.
2139
function fillMissingWords(rawSegments, lastWord) {
2240
const filled = [];
@@ -187,6 +205,9 @@ const store = createStore({
187205
segmentsUnsaved: false,
188206
segmentsSaved: false,
189207
saving: false,
208+
loadingSegments: false,
209+
loadingLetters: false,
210+
loadedLetterBatches: [],
190211
loadedSegments: [],
191212
undoStack: [],
192213
redoStack: [],
@@ -207,8 +228,8 @@ const store = createStore({
207228
mutations: {
208229
SETUP(state, payload) {
209230
state.chapter = payload.chapter;
210-
state.versesCount = payload.versesCount;
211-
state.currentVerseNumber = Number(payload.verse || 1);
231+
state.versesCount = Number(payload.versesCount) || 0;
232+
state.currentVerseNumber = clampVerse(payload.verse, state.versesCount);
212233
state.currentVerseKey = `${state.chapter}:${state.currentVerseNumber}`;
213234
state.recitation = payload.recitation;
214235
state.compareSegment = !!payload.compareSegment
@@ -237,6 +258,33 @@ const store = createStore({
237258
SET_ALERT(state, payload) {
238259
state.alert = payload.text;
239260
},
261+
// Stop bounded playback (word / compare / range preview) the instant the
262+
// media clock passes its end. Driven by Verse's rAF loop so the stop is
263+
// frame-accurate (~16ms) instead of waiting on the coarse `timeupdate`
264+
// event, which can lag far enough behind — especially while the rAF loop is
265+
// running — to let playback overrun the range by seconds.
266+
STOP_AT_PLAYBACK_BOUNDARY(state, payload) {
267+
if (!player) return;
268+
const time = payload.time;
269+
270+
if (state.playingRangeEnd != null && time >= state.playingRangeEnd) {
271+
player.pause();
272+
state.playingRangeEnd = null;
273+
return;
274+
}
275+
276+
if (state.playingCompareEnd != null && time >= state.playingCompareEnd) {
277+
player.pause();
278+
state.playingCompareEnd = null;
279+
return;
280+
}
281+
282+
if (state.playingWord != null && time >= state.playingWordEnd) {
283+
player.pause();
284+
state.playingWord = null;
285+
state.playingWordEnd = null;
286+
}
287+
},
240288
SET_SEGMENTS(state, payload) {
241289
state.segments = payload.segments;
242290
state.originalSegments = JSON.parse(JSON.stringify(state.segments));
@@ -314,18 +362,18 @@ const store = createStore({
314362
if (payload.step) verse = state.currentVerseNumber + Number(payload.step);
315363
else verse = Number(payload.to);
316364

317-
if (verse >= 1 || verse <= state.versesCount) {
318-
state.isManualAyahChange = true;
319-
320-
state.currentVerseNumber = verse;
321-
state.currentTimestamp = 0;
322-
state.currentWord = 1;
365+
verse = clampVerse(verse, state.versesCount);
323366

324-
this.dispatch("LOAD_AYAH", {
325-
verse,
326-
autoPlay: state.autoPlay
327-
});
328-
}
367+
state.isManualAyahChange = true;
368+
369+
state.currentVerseNumber = verse;
370+
state.currentTimestamp = 0;
371+
state.currentWord = 1;
372+
373+
this.dispatch("LOAD_AYAH", {
374+
verse,
375+
autoPlay: state.autoPlay
376+
});
329377
},
330378
TOGGLE_SEGMENTS(state) {
331379
state.showSegments = !state.showSegments;
@@ -1154,6 +1202,12 @@ const store = createStore({
11541202
state.segmentsUnsaved = normalizeForCompare(filledSegments) !== normalizeForCompare(savedBaseline);
11551203
state.segmentsSaved = false;
11561204

1205+
// Pull in letter segments for the batch around this ayah (and prefetch the
1206+
// next batch near a boundary) only when the letters view is on.
1207+
if (state.showLetters && audioType != 'ayah') {
1208+
this.dispatch("ENSURE_LETTER_BATCHES", { verse });
1209+
}
1210+
11571211
setTimeout(() => {
11581212
state.isManualAyahChange = false;
11591213
}, 100);
@@ -1168,10 +1222,10 @@ const store = createStore({
11681222
currentVerseKey
11691223
} = state;
11701224

1171-
this.commit("SET_ALERT", {
1172-
text: "Loading Data...",
1173-
});
1225+
state.loadingSegments = true;
11741226

1227+
// Letter segments are omitted from the bulk load (see #letter_segments in
1228+
// the controller) and fetched lazily per batch as the reviewer navigates.
11751229
$.get(`/${segmentsUrl}/${recitation}/segments.json?chapter_id=${chapter}&a=${Math.random()}`).then((res) => {
11761230
this.commit("SET_SEGMENTS", {
11771231
segments: res.segments,
@@ -1181,8 +1235,6 @@ const store = createStore({
11811235
state.quranicAudioUrl = res.fileUrl;
11821236
}
11831237

1184-
state.alert = null;
1185-
11861238
this.commit("CHANGE_AYAH", {
11871239
to: currentVerseNumber,
11881240
});
@@ -1195,8 +1247,56 @@ const store = createStore({
11951247

11961248
[...new Set(ids)].forEach((id) => this.dispatch('ADD_COMPARE_RECITATION', { recitationId: id }));
11971249
}
1250+
}).always(() => {
1251+
state.loadingSegments = false;
11981252
});
11991253
},
1254+
// Fetch the letter segments for the batch of ayahs containing `verse`, plus
1255+
// the next batch when the reviewer is near the current batch's end. Letters
1256+
// are only needed while the "show letters" option is on, so callers guard on
1257+
// that. Already-loaded batches are skipped so navigation never refetches.
1258+
ENSURE_LETTER_BATCHES({ state, dispatch }, payload) {
1259+
const verse = clampVerse(payload.verse, state.versesCount);
1260+
const start = letterBatchStart(verse);
1261+
dispatch("LOAD_LETTER_BATCH", { start });
1262+
1263+
const end = start + LETTER_BATCH_SIZE - 1;
1264+
if (verse >= end - LETTER_PREFETCH_LOOKAHEAD && end < state.versesCount) {
1265+
dispatch("LOAD_LETTER_BATCH", { start: end + 1 });
1266+
}
1267+
},
1268+
LOAD_LETTER_BATCH({ state }, payload) {
1269+
const { start } = payload;
1270+
if (start > state.versesCount) return;
1271+
if (state.loadedLetterBatches.includes(start)) return;
1272+
1273+
const { segmentsUrl, recitation, chapter } = state;
1274+
const to = Math.min(start + LETTER_BATCH_SIZE - 1, state.versesCount);
1275+
1276+
// Reserve the batch immediately so overlapping triggers (navigation +
1277+
// prefetch) don't fire duplicate requests for it.
1278+
state.loadedLetterBatches.push(start);
1279+
pendingLetterRequests += 1;
1280+
state.loadingLetters = true;
1281+
1282+
$.get(`/${segmentsUrl}/${recitation}/letter_segments.json?chapter_id=${chapter}&from=${start}&to=${to}&a=${Math.random()}`)
1283+
.then((res) => {
1284+
const letters = res.letter_segments || {};
1285+
1286+
Object.keys(letters).forEach((key) => {
1287+
const target = state.segments[key];
1288+
if (target) target.letter_segments = letters[key];
1289+
});
1290+
})
1291+
.catch(() => {
1292+
// Let a failed batch be retried on the next navigation into it.
1293+
state.loadedLetterBatches = state.loadedLetterBatches.filter((batchStart) => batchStart !== start);
1294+
})
1295+
.always(() => {
1296+
pendingLetterRequests = Math.max(0, pendingLetterRequests - 1);
1297+
if (pendingLetterRequests === 0) state.loadingLetters = false;
1298+
});
1299+
},
12001300
ADD_COMPARE_RECITATION({ state }, payload) {
12011301
const recitationId = Number(payload.recitationId);
12021302
if (!recitationId || recitationId === Number(state.recitation)) return;

app/views/surah_audio_files/segments.json.jbuilder

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ json.segments do
1212
json.timestamp_from segment&.timestamp_from
1313
json.timestamp_to segment&.timestamp_to
1414
json.segments segment&.segments || []
15-
json.letter_segments segment&.letter_segments || []
1615
json.set! :words, verse.words.map(&:text_qpc_hafs)
1716
end
1817
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
member do
156156
get :segment_builder
157157
get :segments
158+
get :letter_segments
158159
post :save_segments
159160
post :validate_segments
160161
end

lib/importer/quran_enc.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ def data_source
486486
indonesian_affairs: [/\[\d+\]/, /\[\d+\]/],
487487
french_montada: [/\[\d+\]/, /\[\d+\]/],
488488
english_hilali_khan: [/\[\d+\]/, /\[\d+\]/],
489-
english_saheeh: [/\[\d+\]/, /\[\d+\]-/],
489+
english_saheeh: [/\[\d+\]/, /\[\d+\]/],
490490
hausa_gummi: [/\*+/, /\*+/],
491491
hindi_omari: [/\[\d+\]/, /\d+./],
492492
urdu_junagarhi: [/\(\d+\)/, /(\n)?\(\d+\)/], # OLD [/\*+/, /\*+/],

0 commit comments

Comments
 (0)