@@ -17,6 +17,24 @@ const COMPARE_COLORS = ["#2563eb", "#dc2626", "#9333ea", "#ea580c", "#0d9488", "
1717
1818const 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.
2139function 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 ;
0 commit comments