hetchyy Claude Opus 4.6 commited on
Commit
6a5aa21
·
1 Parent(s): 410852a

5B: Extract JS into static files + js_config.py bridge

Browse files

Move ~1860 lines of inline JS from build_interface() into:
- src/ui/static/animation-core.js (per-segment animation engine)
- src/ui/static/animate-all.js (mega card / Animate All feature)
- src/ui/js_config.py (Python→JS config bridge via window.* globals)

Python string interpolation replaced with window.* references.
interface.py reduced from 2597 to 737 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

src/ui/interface.py CHANGED
@@ -24,6 +24,7 @@ from config import (
24
  LEFT_COLUMN_SCALE, RIGHT_COLUMN_SCALE,
25
  )
26
  from src.ui.styles import build_css
 
27
  from src.pipeline.process import (
28
  process_audio, resegment_audio,
29
  _retranscribe_wrapper, process_audio_json, save_json_export,
@@ -40,1868 +41,7 @@ def build_interface():
40
 
41
  css = build_css()
42
 
43
- js = """
44
- <script>
45
- (function() {
46
- // Warm up browser audio pipeline on first user interaction.
47
- // Uses pointerdown (fires ~50-100ms before click) + AudioContext.resume()
48
- // to prime the audio hardware before the <audio> element's play fires.
49
- var _audioWarmedUp = false;
50
- function _warmupAudio() {
51
- if (_audioWarmedUp) return;
52
- _audioWarmedUp = true;
53
- // 1. Resume/create AudioContext — this is what actually initializes audio hardware
54
- var ctx = new (window.AudioContext || window.webkitAudioContext)();
55
- if (ctx.state === 'suspended') ctx.resume();
56
- // Play a single silent sample to force full pipeline init
57
- var buf = ctx.createBuffer(1, 1, 22050);
58
- var src = ctx.createBufferSource();
59
- src.buffer = buf;
60
- src.connect(ctx.destination);
61
- src.start();
62
- // 2. Also prime HTML5 Audio path with a silent WAV
63
- var a = new Audio('data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAABCxAgACABAAZGF0YQIAAAAAAA==');
64
- a.volume = 0;
65
- a.play().catch(function(){});
66
- document.removeEventListener('pointerdown', _warmupAudio);
67
- document.removeEventListener('touchstart', _warmupAudio);
68
- }
69
- // pointerdown fires before click, giving audio hardware a head start
70
- document.addEventListener('pointerdown', _warmupAudio);
71
- document.addEventListener('touchstart', _warmupAudio, {passive: true});
72
-
73
- // Display mode and granularity (updated via Animation Settings radios)
74
- window.ANIM_PRESETS = """ + str(ANIM_PRESETS).replace("'", '"') + """;
75
- window.ANIM_WINDOW_PREV_MAX = """ + str(ANIM_WINDOW_PREV_MAX) + """;
76
- window.ANIM_WINDOW_AFTER_MAX = """ + str(ANIM_WINDOW_AFTER_MAX) + """;
77
-
78
- // --- localStorage persistence helpers ---
79
- var _ANIM_STORAGE_KEY = 'quran_anim_settings';
80
- function loadAnimSettings() {
81
- try {
82
- var raw = localStorage.getItem(_ANIM_STORAGE_KEY);
83
- return raw ? JSON.parse(raw) : null;
84
- } catch(e) { return null; }
85
- }
86
- function saveAnimSettings() {
87
- try {
88
- var mode = window.ANIM_DISPLAY_MODE;
89
- var s = loadAnimSettings() || {};
90
- s.granularity = window.ANIM_GRANULARITY;
91
- s.mode = mode;
92
- s.verseOnly = !!window.ANIM_VERSE_MODE;
93
- s.color = getComputedStyle(document.documentElement).getPropertyValue('--anim-word-color').trim() || '""" + ANIM_WORD_COLOR + """';
94
- // Read text styling from slider DOM values (more reliable than mega-card inline styles)
95
- var wsEl = document.querySelector('#anim-word-spacing input[type=range],#anim-word-spacing input[type=number]');
96
- var tsEl = document.querySelector('#anim-text-size input[type=range],#anim-text-size input[type=number]');
97
- var lsEl = document.querySelector('#anim-line-spacing input[type=range],#anim-line-spacing input[type=number]');
98
- s.wordSpacing = wsEl ? parseFloat(wsEl.value) : """ + str(MEGA_WORD_SPACING_DEFAULT) + """;
99
- s.textSize = tsEl ? parseFloat(tsEl.value) : """ + str(MEGA_TEXT_SIZE_DEFAULT) + """;
100
- s.lineSpacing = lsEl ? parseFloat(lsEl.value) : """ + str(MEGA_LINE_SPACING_DEFAULT) + """;
101
- // Always keep custom sub-object up to date when in Custom mode
102
- if (mode === 'Custom') {
103
- s.custom = {
104
- prevOpacity: window.ANIM_OPACITY_PREV,
105
- afterOpacity: window.ANIM_OPACITY_AFTER,
106
- prevWords: window.ANIM_WINDOW_PREV,
107
- afterWords: window.ANIM_WINDOW_AFTER
108
- };
109
- }
110
- s.playbackRate = window.ANIM_PLAYBACK_RATE || 1;
111
- localStorage.setItem(_ANIM_STORAGE_KEY, JSON.stringify(s));
112
- } catch(e) {}
113
- }
114
-
115
- // --- Restore from localStorage or use defaults ---
116
- var _saved = loadAnimSettings();
117
- if (_saved) {
118
- window.ANIM_DISPLAY_MODE = _saved.mode || '""" + ANIM_DISPLAY_MODE_DEFAULT + """';
119
- window.ANIM_GRANULARITY = _saved.granularity || '""" + ANIM_GRANULARITY_DEFAULT + """';
120
- window.ANIM_VERSE_MODE = !!_saved.verseOnly;
121
- if (_saved.color) document.documentElement.style.setProperty('--anim-word-color', _saved.color);
122
- var _rPreset = window.ANIM_PRESETS[window.ANIM_DISPLAY_MODE];
123
- if (_rPreset) {
124
- window.ANIM_OPACITY_PREV = _rPreset.prev_opacity;
125
- window.ANIM_OPACITY_AFTER = _rPreset.after_opacity;
126
- window.ANIM_WINDOW_PREV = _rPreset.prev_words;
127
- window.ANIM_WINDOW_AFTER = _rPreset.after_words;
128
- } else if (_saved.custom) {
129
- window.ANIM_OPACITY_PREV = _saved.custom.prevOpacity;
130
- window.ANIM_OPACITY_AFTER = _saved.custom.afterOpacity;
131
- window.ANIM_WINDOW_PREV = _saved.custom.prevWords;
132
- window.ANIM_WINDOW_AFTER = _saved.custom.afterWords;
133
- } else {
134
- window.ANIM_OPACITY_PREV = """ + str(ANIM_OPACITY_PREV_DEFAULT) + """;
135
- window.ANIM_OPACITY_AFTER = """ + str(ANIM_OPACITY_AFTER_DEFAULT) + """;
136
- window.ANIM_WINDOW_PREV = """ + str(ANIM_WINDOW_PREV_DEFAULT) + """;
137
- window.ANIM_WINDOW_AFTER = """ + str(ANIM_WINDOW_AFTER_DEFAULT) + """;
138
- }
139
- } else {
140
- window.ANIM_DISPLAY_MODE = '""" + ANIM_DISPLAY_MODE_DEFAULT + """';
141
- window.ANIM_GRANULARITY = '""" + ANIM_GRANULARITY_DEFAULT + """';
142
- var _initPreset = window.ANIM_PRESETS['""" + ANIM_DISPLAY_MODE_DEFAULT + """'];
143
- window.ANIM_OPACITY_PREV = _initPreset ? _initPreset.prev_opacity : """ + str(ANIM_OPACITY_PREV_DEFAULT) + """;
144
- window.ANIM_OPACITY_AFTER = _initPreset ? _initPreset.after_opacity : """ + str(ANIM_OPACITY_AFTER_DEFAULT) + """;
145
- window.ANIM_WINDOW_PREV = _initPreset ? _initPreset.prev_words : """ + str(ANIM_WINDOW_PREV_DEFAULT) + """;
146
- window.ANIM_WINDOW_AFTER = _initPreset ? _initPreset.after_words : """ + str(ANIM_WINDOW_AFTER_DEFAULT) + """;
147
- window.ANIM_VERSE_MODE = false;
148
- }
149
- window.ANIM_PLAYBACK_RATE = (_saved && _saved.playbackRate) ? _saved.playbackRate : 1;
150
- window._windowPrevGradient = [];
151
- window._windowAfterGradient = [];
152
-
153
- function rebuildWindowGradient() {
154
- var basePrev = window.ANIM_OPACITY_PREV;
155
- var baseAfter = window.ANIM_OPACITY_AFTER;
156
- var pc = window.ANIM_WINDOW_PREV;
157
- var ac = window.ANIM_WINDOW_AFTER;
158
- window._windowPrevGradient = [];
159
- window._windowAfterGradient = [];
160
- // At max: empty gradient signals "show all at flat base opacity"
161
- if (pc < window.ANIM_WINDOW_PREV_MAX) {
162
- for (var d = 1; d <= pc; d++) {
163
- window._windowPrevGradient.push(String(basePrev * (pc - d + 1) / pc));
164
- }
165
- }
166
- if (ac < window.ANIM_WINDOW_AFTER_MAX) {
167
- for (var d = 1; d <= ac; d++) {
168
- window._windowAfterGradient.push(String(baseAfter * (ac - d + 1) / ac));
169
- }
170
- }
171
- }
172
- rebuildWindowGradient();
173
-
174
- // Activate a lazy-loaded audio element (set src, show controls, hide play button)
175
- function activateAudio(audio) {
176
- if (audio.hasAttribute('controls')) return audio;
177
- audio.src = audio.dataset.src;
178
- audio.controls = true;
179
- audio.style.display = '';
180
- var playBtn = audio.parentElement && audio.parentElement.querySelector('.play-btn');
181
- if (playBtn) playBtn.style.display = 'none';
182
- return audio;
183
- }
184
-
185
- // =====================================================================
186
- // Animation Debug Logging
187
- // Enable via: window.ANIM_DEBUG = true; in browser console
188
- // =====================================================================
189
- window.ANIM_DEBUG = false; // Set to true to enable animation debug logging
190
- function animDebug(category, msg, data) {
191
- if (!window.ANIM_DEBUG) return;
192
- var prefix = '[ANIM:' + category + ']';
193
- if (data !== undefined) {
194
- console.log(prefix, msg, data);
195
- } else {
196
- console.log(prefix, msg);
197
- }
198
- }
199
- function dumpCacheTimestamps(cache, label) {
200
- if (!window.ANIM_DEBUG) return;
201
- console.group('[ANIM:CACHE] ' + label + ' (' + cache.length + ' entries)');
202
- cache.forEach(function(item, idx) {
203
- var text = item.el.textContent.substring(0, 20);
204
- var gid = item.groupId || '-';
205
- console.log(idx + ': "' + text + '" start=' + item.start.toFixed(3) + ' end=' + item.end.toFixed(3) + ' groupId=' + gid);
206
- });
207
- if (cache._groupIndex) {
208
- console.log('_groupIndex:', JSON.parse(JSON.stringify(cache._groupIndex)));
209
- }
210
- console.groupEnd();
211
- }
212
-
213
- // Cache elements and timing data for a given selector
214
- // Also builds group index for letter groups (chars with same data-group-id)
215
- function initCacheFor(card, selector) {
216
- var elements = Array.from(card.querySelectorAll(selector));
217
- var cache = elements.map(function(el, idx) {
218
- return {
219
- el: el,
220
- start: parseFloat(el.dataset.start),
221
- end: parseFloat(el.dataset.end),
222
- groupId: el.dataset.groupId || null,
223
- cacheIdx: idx
224
- };
225
- });
226
- // Build group index: groupId → [cacheIdx, ...]
227
- var groupIndex = {};
228
- cache.forEach(function(item) {
229
- if (item.groupId) {
230
- if (!groupIndex[item.groupId]) {
231
- groupIndex[item.groupId] = [];
232
- }
233
- groupIndex[item.groupId].push(item.cacheIdx);
234
- }
235
- });
236
- cache._groupIndex = groupIndex;
237
- animDebug('INIT', 'initCacheFor("' + selector + '"): ' + cache.length + ' elements, ' + Object.keys(groupIndex).length + ' groups');
238
- return cache;
239
- }
240
-
241
- // Apply class to element and all members of its letter group
242
- function applyClassToGroup(cache, idx, className, add) {
243
- var item = cache[idx];
244
- if (!item) {
245
- animDebug('CLASS', 'applyClassToGroup SKIP: no item at idx=' + idx);
246
- return;
247
- }
248
- var text = item.el.textContent.substring(0, 10);
249
- animDebug('CLASS', (add ? '+' : '-') + className + ' idx=' + idx + ' "' + text + '" groupId=' + (item.groupId || '-'));
250
- if (add) {
251
- item.el.classList.add(className);
252
- } else {
253
- item.el.classList.remove(className);
254
- }
255
- // Also apply to all group members if this element has a groupId
256
- if (item.groupId && cache._groupIndex) {
257
- var groupMembers = cache._groupIndex[item.groupId] || [];
258
- if (groupMembers.length > 1) {
259
- animDebug('CLASS', ' -> propagating to group members:', groupMembers);
260
- }
261
- groupMembers.forEach(function(memberIdx) {
262
- if (memberIdx !== idx) {
263
- if (add) {
264
- cache[memberIdx].el.classList.add(className);
265
- } else {
266
- cache[memberIdx].el.classList.remove(className);
267
- }
268
- }
269
- });
270
- }
271
- }
272
-
273
- // Return the active cache based on current granularity setting
274
- function getActiveCache(audio) {
275
- return window.ANIM_GRANULARITY === 'Characters' ? audio._cacheChars : audio._cacheWords;
276
- }
277
-
278
- // Apply opacity to element and all members of its letter group
279
- function applyOpacityToGroup(cache, idx, opacity) {
280
- var item = cache[idx];
281
- if (!item) {
282
- animDebug('OPACITY', 'applyOpacityToGroup SKIP: no item at idx=' + idx);
283
- return;
284
- }
285
- var text = item.el.textContent.substring(0, 10);
286
- animDebug('OPACITY', 'idx=' + idx + ' "' + text + '" opacity=' + (opacity === null ? 'CLEAR' : opacity) + ' groupId=' + (item.groupId || '-'));
287
- if (opacity === null) {
288
- item.el.style.removeProperty('opacity');
289
- } else {
290
- item.el.style.opacity = opacity;
291
- }
292
- // Also apply to all group members if this element has a groupId
293
- if (item.groupId && cache._groupIndex) {
294
- var groupMembers = cache._groupIndex[item.groupId] || [];
295
- if (groupMembers.length > 1) {
296
- animDebug('OPACITY', ' -> propagating to group members:', groupMembers);
297
- }
298
- groupMembers.forEach(function(memberIdx) {
299
- if (memberIdx !== idx) {
300
- if (opacity === null) {
301
- cache[memberIdx].el.style.removeProperty('opacity');
302
- } else {
303
- cache[memberIdx].el.style.opacity = opacity;
304
- }
305
- }
306
- });
307
- }
308
- }
309
-
310
- // Build group index for a cache array (extracts groupId and builds _groupIndex)
311
- // Used for megacard caches which are built manually without initCacheFor()
312
- function buildGroupIndex(cache) {
313
- var groupIndex = {};
314
- cache.forEach(function(item, idx) {
315
- var gid = item.el.dataset.groupId;
316
- if (gid) {
317
- item.groupId = gid;
318
- if (!groupIndex[gid]) groupIndex[gid] = [];
319
- groupIndex[gid].push(idx);
320
- }
321
- });
322
- cache._groupIndex = groupIndex;
323
- var groupCount = Object.keys(groupIndex).length;
324
- animDebug('GROUP', 'buildGroupIndex: ' + cache.length + ' elements, ' + groupCount + ' groups');
325
- if (window.ANIM_DEBUG && groupCount > 0) {
326
- for (var gid in groupIndex) {
327
- if (groupIndex[gid].length > 1) {
328
- animDebug('GROUP', ' group "' + gid + '": indices ' + JSON.stringify(groupIndex[gid]));
329
- }
330
- }
331
- }
332
- }
333
-
334
- // Window mode: track active state for live slider updates
335
- window._windowActiveCache = null;
336
- window._windowActiveIdx = -1;
337
- window._windowLastPc = 0;
338
- window._windowLastAc = 0;
339
- window._windowLastPcAll = false;
340
- window._windowLastAcAll = false;
341
- window._windowSettingsVersion = 0; // Incremented when sliders change, so tick() can detect
342
-
343
- // Window mode: apply per-element opacity gradient around active index
344
- function applyWindowOpacity(cache, newIdx, prevIdx) {
345
- animDebug('WINDOW', 'applyWindowOpacity newIdx=' + newIdx + ' prevIdx=' + prevIdx + ' cacheLen=' + cache.length);
346
- // If prevIdx is unknown (e.g. after a timing gap between words),
347
- // fall back to the last index we applied so the old window gets cleared.
348
- if (prevIdx < 0 && window._windowActiveIdx >= 0 && window._windowActiveCache === cache) {
349
- animDebug('WINDOW', ' -> using fallback prevIdx=' + window._windowActiveIdx);
350
- prevIdx = window._windowActiveIdx;
351
- }
352
- var prevGrad = window._windowPrevGradient;
353
- var afterGrad = window._windowAfterGradient;
354
- var pc = prevGrad.length;
355
- var ac = afterGrad.length;
356
- var pcAll = (window.ANIM_WINDOW_PREV >= window.ANIM_WINDOW_PREV_MAX);
357
- var acAll = (window.ANIM_WINDOW_AFTER >= window.ANIM_WINDOW_AFTER_MAX);
358
- var oldPcAll = window._windowLastPcAll;
359
- var oldAcAll = window._windowLastAcAll;
360
- var basePrev = String(window.ANIM_OPACITY_PREV);
361
- var baseAfter = String(window.ANIM_OPACITY_AFTER);
362
- animDebug('WINDOW', ' verseMode=' + window.ANIM_VERSE_MODE + ' pcAll=' + pcAll + ' acAll=' + acAll + ' basePrev=' + basePrev + ' baseAfter=' + baseAfter);
363
- // Verse mode: show only current-verse words, hide everything else
364
- if (window.ANIM_VERSE_MODE) {
365
- var activeEl = cache[newIdx].el;
366
- var activePos = activeEl.dataset.pos || (activeEl.closest('.word') || {}).dataset?.pos || '';
367
- var vp = activePos.split(':');
368
- var activeVerse = vp.length >= 2 ? vp[0] + ':' + vp[1] : '';
369
- // Track which group IDs we've already handled to avoid duplicates
370
- var handledGroups = {};
371
- for (var i = 0; i < cache.length; i++) {
372
- // Skip if this element's group was already handled
373
- var gid = cache[i].groupId;
374
- if (gid && handledGroups[gid]) continue;
375
- if (gid) handledGroups[gid] = true;
376
- if (i === newIdx) {
377
- applyOpacityToGroup(cache, i, null);
378
- continue;
379
- }
380
- var el = cache[i].el;
381
- var pos = el.dataset.pos || (el.closest('.word') || {}).dataset?.pos || '';
382
- var wp = pos.split(':');
383
- var wverse = wp.length >= 2 ? wp[0] + ':' + wp[1] : '';
384
- if (wverse === activeVerse) {
385
- // Same verse: normal gradient opacity
386
- if (i < newIdx || !cache[i].el.classList.contains('reached')) {
387
- applyOpacityToGroup(cache, i, (i < newIdx) ? basePrev : baseAfter);
388
- }
389
- } else if (i < newIdx) {
390
- // Different verse, before current: always hide past verses
391
- applyOpacityToGroup(cache, i, '0');
392
- } else {
393
- // Different verse, after current: hide future verses in verse-only mode
394
- applyOpacityToGroup(cache, i, '0');
395
- }
396
- }
397
- window._windowActiveCache = cache;
398
- window._windowActiveIdx = newIdx;
399
- return;
400
- }
401
- // Fast path: All→All steady state — only 2 elements change (with group support)
402
- if (prevIdx >= 0 && newIdx >= 0 && pcAll && acAll && oldPcAll && oldAcAll) {
403
- applyOpacityToGroup(cache, prevIdx, basePrev);
404
- applyOpacityToGroup(cache, newIdx, null);
405
- window._windowActiveCache = cache;
406
- window._windowActiveIdx = newIdx;
407
- window._windowLastPc = pc; window._windowLastAc = ac;
408
- window._windowLastPcAll = pcAll; window._windowLastAcAll = acAll;
409
- return;
410
- }
411
- // Clear old window range
412
- if (prevIdx >= 0) {
413
- if (oldPcAll || oldAcAll) {
414
- // Previous state had "all" — clear every element
415
- for (var i = 0; i < cache.length; i++) {
416
- cache[i].el.style.removeProperty('opacity');
417
- }
418
- } else {
419
- var clearPc = Math.max(pc, window._windowLastPc);
420
- var clearAc = Math.max(ac, window._windowLastAc);
421
- var clearStart = Math.max(0, prevIdx - clearPc);
422
- var clearEnd = Math.min(cache.length - 1, prevIdx + clearAc);
423
- for (var i = clearStart; i <= clearEnd; i++) {
424
- cache[i].el.style.removeProperty('opacity');
425
- }
426
- }
427
- }
428
- // Track state for live slider updates
429
- window._windowActiveCache = cache;
430
- window._windowActiveIdx = newIdx;
431
- window._windowLastPc = pc;
432
- window._windowLastAc = ac;
433
- window._windowLastPcAll = pcAll;
434
- window._windowLastAcAll = acAll;
435
- if (newIdx < 0) return;
436
- // Apply previous elements (with group support)
437
- if (pcAll) {
438
- for (var i = 0; i < newIdx; i++) {
439
- applyOpacityToGroup(cache, i, basePrev);
440
- }
441
- } else {
442
- for (var p = 0; p < pc; p++) {
443
- var idx = newIdx - (p + 1);
444
- if (idx < 0) break;
445
- applyOpacityToGroup(cache, idx, prevGrad[p]);
446
- }
447
- }
448
- // Apply after elements (upcoming words always get opacity set for
449
- // proper word-by-word animation, even if they have 'reached' from
450
- // a previous segment) — with group support
451
- if (acAll) {
452
- for (var i = newIdx + 1; i < cache.length; i++) {
453
- applyOpacityToGroup(cache, i, baseAfter);
454
- }
455
- } else {
456
- for (var a = 0; a < ac; a++) {
457
- var idx = newIdx + (a + 1);
458
- if (idx >= cache.length) break;
459
- applyOpacityToGroup(cache, idx, afterGrad[a]);
460
- }
461
- }
462
- // Fade previously-active word from full opacity to its new level (with group support)
463
- if (prevIdx >= 0 && prevIdx !== newIdx) {
464
- var tgt = cache[prevIdx].el.style.opacity || '0';
465
- applyOpacityToGroup(cache, prevIdx, tgt);
466
- }
467
- // Reconcile group opacities: grouped characters should appear as
468
- // one visual unit, using the max opacity from any member
469
- if (cache._groupIndex) {
470
- var gids = Object.keys(cache._groupIndex);
471
- for (var g = 0; g < gids.length; g++) {
472
- var members = cache._groupIndex[gids[g]];
473
- if (members.length <= 1) continue;
474
- // If any member is active, set all to full opacity
475
- var anyActive = false;
476
- var maxOp = -1;
477
- for (var m = 0; m < members.length; m++) {
478
- if (cache[members[m]].el.classList.contains('active')) {
479
- anyActive = true;
480
- break;
481
- }
482
- var op = cache[members[m]].el.style.opacity;
483
- if (op !== '') {
484
- var val = parseFloat(op);
485
- if (!isNaN(val) && val > maxOp) maxOp = val;
486
- }
487
- }
488
- if (anyActive) {
489
- for (var m = 0; m < members.length; m++) {
490
- cache[members[m]].el.style.opacity = '1';
491
- }
492
- } else if (maxOp > 0) {
493
- var maxOpStr = String(maxOp);
494
- for (var m = 0; m < members.length; m++) {
495
- cache[members[m]].el.style.opacity = maxOpStr;
496
- }
497
- }
498
- // maxOp <= 0: group is hidden (outside window), leave as-is
499
- }
500
- }
501
- }
502
-
503
- // Re-apply window opacity immediately (called when sliders change mid-animation)
504
- function reapplyWindowNow() {
505
- var cache = window._windowActiveCache;
506
- var idx = window._windowActiveIdx;
507
- if (!cache || idx < 0) return;
508
- applyWindowOpacity(cache, idx, idx);
509
- }
510
-
511
- // Replace numeric value with "All" when slider is at maximum
512
- function updateWindowMaxLabel(elemId, val, maxVal) {
513
- var el = document.getElementById(elemId);
514
- if (!el) return;
515
- var numInput = el.querySelector('input[type="number"]');
516
- if (!numInput) return;
517
- if (val >= maxVal) {
518
- numInput.style.display = 'none';
519
- var maxSpan = el.querySelector('.max-label');
520
- if (!maxSpan) {
521
- maxSpan = document.createElement('span');
522
- maxSpan.className = 'max-label';
523
- maxSpan.style.cssText = 'font-weight: bold; opacity: 0.85;';
524
- numInput.parentNode.insertBefore(maxSpan, numInput.nextSibling);
525
- }
526
- maxSpan.textContent = 'All';
527
- maxSpan.style.display = '';
528
- } else {
529
- numInput.style.display = '';
530
- var maxSpan = el.querySelector('.max-label');
531
- if (maxSpan) maxSpan.style.display = 'none';
532
- }
533
- // Always inject a hint at the right end of the slider track
534
- if (!el.querySelector('.max-hint')) {
535
- var rangeWrap = el.querySelector('input[type="range"]');
536
- if (rangeWrap) {
537
- var hint = document.createElement('span');
538
- hint.className = 'max-hint';
539
- hint.textContent = 'All';
540
- hint.style.cssText = 'position: absolute; right: 0; bottom: -1.2em; font-size: 0.7em; opacity: 0.5;';
541
- var parent = rangeWrap.parentNode;
542
- if (parent) {
543
- parent.style.position = 'relative';
544
- parent.appendChild(hint);
545
- }
546
- }
547
- }
548
- }
549
-
550
- // Expose to global scope so Gradio inline js= callbacks can call them
551
- window.rebuildWindowGradient = rebuildWindowGradient;
552
- window.reapplyWindowNow = reapplyWindowNow;
553
- window.updateWindowMaxLabel = updateWindowMaxLabel;
554
- window.saveAnimSettings = saveAnimSettings;
555
- window.loadAnimSettings = loadAnimSettings;
556
-
557
- // Inject "All" hints on slider tracks once Gradio renders them
558
- setTimeout(function() {
559
- updateWindowMaxLabel('anim-window-prev', window.ANIM_WINDOW_PREV, window.ANIM_WINDOW_PREV_MAX);
560
- updateWindowMaxLabel('anim-window-after', window.ANIM_WINDOW_AFTER, window.ANIM_WINDOW_AFTER_MAX);
561
- }, 500);
562
-
563
- // Clear inline opacity from all words/chars in a card (Window mode cleanup)
564
- // Applies mode's prev_opacity instead of removing opacity entirely
565
- function clearWindowOpacity(card) {
566
- var prevOp = window.ANIM_OPACITY_PREV;
567
- card.querySelectorAll('.word, .char').forEach(function(el) {
568
- // Apply mode's prev_opacity consistently:
569
- // - Reveal/Fade (1.0): full visibility
570
- // - Spotlight (0.3): dimmed
571
- // - Isolate/Consume (0): hidden/disappear
572
- if (prevOp >= 1) {
573
- el.style.removeProperty('opacity');
574
- } else {
575
- el.style.opacity = String(prevOp);
576
- }
577
- });
578
- }
579
-
580
- function clearHighlights(card) {
581
- card.querySelectorAll('.word.active, .word.reached, .char.active, .char.reached').forEach(function(w) {
582
- w.classList.remove('active', 'reached');
583
- });
584
- clearWindowOpacity(card);
585
- card.classList.remove('anim-window', 'anim-chars');
586
- }
587
-
588
- function stopAnimation(audio, card) {
589
- if (audio._rafId) {
590
- cancelAnimationFrame(audio._rafId);
591
- audio._rafId = null;
592
- }
593
- if (card) {
594
- // Apply mode's prev_opacity to last active word before clearing
595
- var activeEl = card.querySelector('.word.active, .char.active');
596
- if (activeEl && window.ANIM_OPACITY_PREV < 1) {
597
- activeEl.style.opacity = String(window.ANIM_OPACITY_PREV);
598
- }
599
- clearHighlights(card);
600
- }
601
- }
602
-
603
- function startAnimation(audio, card) {
604
- var lastWordIdx = -1;
605
- var lastGranularity = window.ANIM_GRANULARITY;
606
- var lastOpacityPrev = window.ANIM_OPACITY_PREV;
607
- var lastSeenVersion = window._windowSettingsVersion;
608
- // Segment audio is trimmed, so currentTime starts at 0.
609
- // Word timestamps are absolute, so we need to add segment offset.
610
- var segOffset = parseFloat(card.dataset.startTime) || 0;
611
-
612
- function tick() {
613
- if (audio.paused || audio.ended) return;
614
- var wordCache = getActiveCache(audio);
615
- var currentTime = audio.currentTime + segOffset;
616
-
617
- // Granularity switched mid-animation — clear old highlights and reset
618
- if (window.ANIM_GRANULARITY !== lastGranularity) {
619
- animDebug('TICK', 'Granularity changed: ' + lastGranularity + ' -> ' + window.ANIM_GRANULARITY);
620
- card.querySelectorAll('.word.active, .word.reached, .char.active, .char.reached').forEach(function(w) {
621
- w.classList.remove('active', 'reached');
622
- });
623
- clearWindowOpacity(card);
624
- lastWordIdx = -1;
625
- lastGranularity = window.ANIM_GRANULARITY;
626
- }
627
-
628
- // Mode changed mid-animation — refresh all reached words with new opacity
629
- if (window.ANIM_OPACITY_PREV !== lastOpacityPrev) {
630
- animDebug('TICK', 'Mode changed: prevOp ' + lastOpacityPrev + ' -> ' + window.ANIM_OPACITY_PREV);
631
- card.querySelectorAll('.word.reached, .char.reached').forEach(function(el) {
632
- if (window.ANIM_OPACITY_PREV >= 1) {
633
- el.style.removeProperty('opacity');
634
- } else {
635
- el.style.opacity = String(window.ANIM_OPACITY_PREV);
636
- }
637
- });
638
- lastOpacityPrev = window.ANIM_OPACITY_PREV;
639
- }
640
-
641
- // Slider settings changed mid-animation — reapply window opacity
642
- if (window._windowSettingsVersion !== lastSeenVersion) {
643
- if (lastWordIdx >= 0) {
644
- applyWindowOpacity(wordCache, lastWordIdx, lastWordIdx);
645
- }
646
- lastSeenVersion = window._windowSettingsVersion;
647
- }
648
-
649
- var newWordIdx = -1;
650
- var searchPath = '';
651
- // Fast path: check current word, then next (covers ~99% of frames)
652
- if (lastWordIdx >= 0 && lastWordIdx < wordCache.length &&
653
- currentTime >= wordCache[lastWordIdx].start && currentTime < wordCache[lastWordIdx].end) {
654
- newWordIdx = lastWordIdx;
655
- searchPath = 'same';
656
- } else if (lastWordIdx + 1 < wordCache.length &&
657
- currentTime >= wordCache[lastWordIdx + 1].start && currentTime < wordCache[lastWordIdx + 1].end) {
658
- newWordIdx = lastWordIdx + 1;
659
- searchPath = 'next';
660
- } else {
661
- // Fallback: full scan (seeking, granularity switch, etc.)
662
- searchPath = 'scan';
663
- for (var i = 0; i < wordCache.length; i++) {
664
- if (currentTime >= wordCache[i].start && currentTime < wordCache[i].end) {
665
- newWordIdx = i;
666
- break;
667
- }
668
- }
669
- // Clamp to last word when past its end but audio hasn't ended yet
670
- if (newWordIdx === -1 && wordCache.length > 0 && currentTime >= wordCache[wordCache.length - 1].start) {
671
- newWordIdx = wordCache.length - 1;
672
- searchPath = 'clamp';
673
- }
674
- }
675
-
676
- // Only update DOM if word changed
677
- if (newWordIdx !== lastWordIdx) {
678
- var newText = newWordIdx >= 0 ? wordCache[newWordIdx].el.textContent.substring(0, 15) : '-';
679
- animDebug('TICK', 'idx change: ' + lastWordIdx + ' -> ' + newWordIdx + ' (' + searchPath + ') t=' + currentTime.toFixed(3) + ' "' + newText + '"');
680
- if (newWordIdx === -1 && wordCache.length > 0) {
681
- // No match - log surrounding timing info
682
- var first = wordCache[0];
683
- var last = wordCache[wordCache.length - 1];
684
- animDebug('TICK', ' NO MATCH: t=' + currentTime.toFixed(3) + ' cache[0]=[' + first.start.toFixed(3) + ',' + first.end.toFixed(3) + '] cache[' + (wordCache.length-1) + ']=[' + last.start.toFixed(3) + ',' + last.end.toFixed(3) + ']');
685
- }
686
- if (lastWordIdx >= 0 && lastWordIdx < wordCache.length) {
687
- applyClassToGroup(wordCache, lastWordIdx, 'active', false);
688
- applyClassToGroup(wordCache, lastWordIdx, 'reached', true);
689
- }
690
- if (newWordIdx >= 0) {
691
- applyClassToGroup(wordCache, newWordIdx, 'active', true);
692
- if (lastWordIdx === -1) {
693
- // First highlight — catch up any skipped words (with group support)
694
- for (var j = 0; j < newWordIdx; j++) {
695
- applyClassToGroup(wordCache, j, 'reached', true);
696
- }
697
- }
698
- }
699
- if (newWordIdx >= 0) {
700
- applyWindowOpacity(wordCache, newWordIdx, lastWordIdx);
701
- }
702
- lastWordIdx = newWordIdx;
703
- }
704
- }
705
-
706
- function rafLoop() {
707
- tick();
708
- if (!audio.paused && !audio.ended) {
709
- audio._rafId = requestAnimationFrame(rafLoop);
710
- }
711
- }
712
- audio._rafId = requestAnimationFrame(rafLoop);
713
- }
714
-
715
- function toggleAnimation(btn) {
716
- var card = btn.closest('.segment-card');
717
- if (!card) return;
718
- var audio = card.querySelector('audio');
719
- if (!audio) return;
720
-
721
- var isActive = btn.classList.toggle('active');
722
- if (isActive) {
723
- btn.textContent = 'Stop';
724
- // Apply window engine class to card
725
- card.classList.add('anim-window');
726
- if (window.ANIM_GRANULARITY === 'Characters') {
727
- card.classList.add('anim-chars');
728
- }
729
- // Build both caches upfront for live granularity switching
730
- audio._cacheWords = initCacheFor(card, '.segment-text .word');
731
- audio._cacheChars = initCacheFor(card, '.segment-text .char');
732
- animDebug('START', 'toggleAnimation: words=' + audio._cacheWords.length + ' chars=' + audio._cacheChars.length + ' granularity=' + window.ANIM_GRANULARITY);
733
- dumpCacheTimestamps(audio._cacheWords, 'Words');
734
- dumpCacheTimestamps(audio._cacheChars, 'Chars');
735
- activateAudio(audio);
736
- startAnimation(audio, card);
737
- audio.play();
738
- } else {
739
- btn.textContent = 'Animate';
740
- audio.pause();
741
- stopAnimation(audio, card);
742
- }
743
- }
744
-
745
-
746
- // =====================================================================
747
- // Surah name ligature map (font triggers)
748
- // =====================================================================
749
- var surahLigatures = __SURAH_LIGATURES_JSON__;
750
-
751
- // =====================================================================
752
- // Animate All — continuous text stream with repetition handling
753
- // =====================================================================
754
- var animateAllState = {
755
- active: false,
756
- currentIdx: 0,
757
- segments: [], // [{startTime, endTime, cacheWords, cacheChars, wordEls}]
758
- rafId: null,
759
- megaCard: null,
760
- textFlow: null, // the unified .mega-text-flow container
761
- btn: null,
762
- unifiedCacheWords: [],
763
- unifiedCacheChars: [],
764
- unifiedAudio: null, // Single audio element for entire recording
765
- accordionParent: null,
766
- accordionNextSibling: null,
767
- completedIdx: -1
768
- };
769
-
770
- function buildMegaCard() {
771
- var container = document.querySelector('.segments-container');
772
- if (!container) return null;
773
-
774
- // Create unified audio element from full audio URL
775
- var fullAudioUrl = container.dataset.fullAudio;
776
- var unifiedAudio = null;
777
- if (fullAudioUrl) {
778
- unifiedAudio = document.createElement('audio');
779
- unifiedAudio.src = fullAudioUrl;
780
- unifiedAudio.preload = 'auto';
781
- unifiedAudio.style.display = 'none';
782
- }
783
-
784
- var cards = Array.from(container.querySelectorAll('.segment-card'));
785
- var mega = document.createElement('div');
786
- mega.className = 'mega-card';
787
- var textFlow = document.createElement('div');
788
- textFlow.className = 'mega-text-flow';
789
-
790
- var renderedPositions = {}; // data-pos string -> DOM element in textFlow
791
- var renderedCharEls = {}; // data-pos string -> Array of .char elements
792
- var renderedMarkers = {}; // "surah:marker" -> true, to avoid duplicate verse markers
793
- var frag = document.createDocumentFragment();
794
- var segments = [];
795
- var lastSurah = null;
796
- var pendingSpecial = null; // buffered special text to flush after surah separator
797
-
798
- // Helper: flush pending special line into fragment
799
- function flushPendingSpecial() {
800
- if (!pendingSpecial) return;
801
- var prevChild = frag.lastChild;
802
- if (prevChild && prevChild.classList && prevChild.classList.contains('mega-special-line')) {
803
- prevChild.textContent += ' ' + pendingSpecial;
804
- } else {
805
- var specialLine = document.createElement('div');
806
- specialLine.className = 'mega-special-line';
807
- specialLine.textContent = pendingSpecial;
808
- frag.appendChild(specialLine);
809
- }
810
- pendingSpecial = null;
811
- }
812
-
813
- cards.forEach(function(card) {
814
- var btn = card.querySelector('.animate-btn');
815
- var ref = (card.dataset.matchedRef || '').trim();
816
- var isSpecial = (ref === 'Basmala' || ref === "Isti'adha");
817
-
818
- if (!btn || btn.disabled) {
819
- // Special segment without timestamps — buffer static text
820
- if (isSpecial || ref === '') {
821
- var textEl = card.querySelector('.segment-text');
822
- if (textEl) {
823
- var txt = textEl.textContent.trim();
824
- if (txt) {
825
- if (pendingSpecial) {
826
- pendingSpecial += ' ' + txt;
827
- } else {
828
- pendingSpecial = txt;
829
- }
830
- }
831
- }
832
- }
833
- return;
834
- }
835
-
836
- // Animated special segment (has timestamps) — centered line with word animation
837
- if (isSpecial) {
838
- var textEl = card.querySelector('.segment-text');
839
- if (!textEl) return;
840
-
841
- // Extract segment boundaries from card data attributes
842
- var segStartTime = parseFloat(card.dataset.startTime) || 0;
843
- var segEndTime = parseFloat(card.dataset.endTime) || 0;
844
-
845
- // Flush any buffered specials first
846
- flushPendingSpecial();
847
-
848
- var specialDiv;
849
- var prevChild = frag.lastChild;
850
- if (prevChild && prevChild.classList && prevChild.classList.contains('mega-special-line')) {
851
- specialDiv = prevChild;
852
- specialDiv.appendChild(document.createTextNode(' '));
853
- } else {
854
- specialDiv = document.createElement('div');
855
- specialDiv.className = 'mega-special-line';
856
- frag.appendChild(specialDiv);
857
- }
858
-
859
- var sourceWords = Array.from(textEl.querySelectorAll('.word'));
860
- var segWordEls = [];
861
- var segWordTimings = [];
862
- var segCharTimings = [];
863
- var unifiedCharEls = {};
864
-
865
- sourceWords.forEach(function(node) {
866
- var pos = node.dataset.pos;
867
- var clone = node.cloneNode(true);
868
- specialDiv.appendChild(document.createTextNode(' '));
869
- specialDiv.appendChild(clone);
870
- if (pos) renderedPositions[pos] = clone;
871
- segWordEls.push(clone);
872
-
873
- segWordTimings.push({
874
- start: parseFloat(node.dataset.start) || 0,
875
- end: parseFloat(node.dataset.end) || 0
876
- });
877
-
878
- var wordIdx = segWordEls.length - 1;
879
- var chars = Array.from(clone.children);
880
- unifiedCharEls[wordIdx] = chars;
881
- if (pos) renderedCharEls[pos] = chars;
882
-
883
- Array.from(node.children).forEach(function(srcChar) {
884
- segCharTimings.push({
885
- start: parseFloat(srcChar.dataset.start) || 0,
886
- end: parseFloat(srcChar.dataset.end) || 0,
887
- parentWordIdx: wordIdx
888
- });
889
- });
890
- });
891
-
892
- if (segWordEls.length === 0) return;
893
-
894
- var cacheWords = segWordEls.map(function(el, j) {
895
- return { el: el, start: segWordTimings[j].start, end: segWordTimings[j].end };
896
- });
897
- var cacheChars = [];
898
- var charCountPerWord = {};
899
- segCharTimings.forEach(function(ct) {
900
- var wIdx = ct.parentWordIdx;
901
- if (charCountPerWord[wIdx] === undefined) charCountPerWord[wIdx] = 0;
902
- var charIdxInWord = charCountPerWord[wIdx]++;
903
- var unifiedChars = unifiedCharEls[wIdx];
904
- if (unifiedChars && charIdxInWord < unifiedChars.length) {
905
- cacheChars.push({ el: unifiedChars[charIdxInWord], start: ct.start, end: ct.end });
906
- }
907
- });
908
-
909
- segments.push({
910
- startTime: segStartTime,
911
- endTime: segEndTime,
912
- cacheWords: cacheWords,
913
- cacheChars: cacheChars,
914
- wordEls: segWordEls
915
- });
916
- return;
917
- }
918
-
919
- var textEl = card.querySelector('.segment-text');
920
- if (!textEl) return;
921
-
922
- // Extract segment boundaries from card data attributes
923
- var segStartTime = parseFloat(card.dataset.startTime) || 0;
924
- var segEndTime = parseFloat(card.dataset.endTime) || 0;
925
-
926
- // Detect fused special prefix: leading .word elements with :0:0: in data-pos
927
- var allWords = Array.from(textEl.querySelectorAll('.word'));
928
- var fusedWords = [];
929
- var fusedHasTimestamps = false;
930
- for (var fi = 0; fi < allWords.length; fi++) {
931
- var fpos = allWords[fi].dataset.pos || '';
932
- if (fpos && fpos.indexOf(':0:0:') !== -1) {
933
- fusedWords.push(allWords[fi]);
934
- if (allWords[fi].dataset.start) fusedHasTimestamps = true;
935
- } else if (fpos) {
936
- break; // stop at first verse word
937
- } else {
938
- // No data-pos at all (old-style) — static fallback
939
- fusedWords = [];
940
- break;
941
- }
942
- }
943
-
944
- if (fusedWords.length > 0 && fusedHasTimestamps) {
945
- // Animated fused prefix — clone into a mega-special-line, share audio
946
- flushPendingSpecial();
947
- var fusedDiv;
948
- var prevChild = frag.lastChild;
949
- if (prevChild && prevChild.classList && prevChild.classList.contains('mega-special-line')) {
950
- fusedDiv = prevChild;
951
- fusedDiv.appendChild(document.createTextNode(' '));
952
- } else {
953
- fusedDiv = document.createElement('div');
954
- fusedDiv.className = 'mega-special-line';
955
- frag.appendChild(fusedDiv);
956
- }
957
- var fusedWordEls = [];
958
- var fusedWordTimings = [];
959
- var fusedCharTimings = [];
960
- var fusedCharEls = {};
961
- fusedWords.forEach(function(node) {
962
- var clone = node.cloneNode(true);
963
- fusedDiv.appendChild(document.createTextNode(' '));
964
- fusedDiv.appendChild(clone);
965
- var pos = node.dataset.pos;
966
- if (pos) renderedPositions[pos] = clone;
967
- fusedWordEls.push(clone);
968
- fusedWordTimings.push({
969
- start: parseFloat(node.dataset.start) || 0,
970
- end: parseFloat(node.dataset.end) || 0
971
- });
972
- var wordIdx = fusedWordEls.length - 1;
973
- var chars = Array.from(clone.children);
974
- fusedCharEls[wordIdx] = chars;
975
- if (pos) renderedCharEls[pos] = chars;
976
- Array.from(node.children).forEach(function(srcChar) {
977
- fusedCharTimings.push({
978
- start: parseFloat(srcChar.dataset.start) || 0,
979
- end: parseFloat(srcChar.dataset.end) || 0,
980
- parentWordIdx: wordIdx
981
- });
982
- });
983
- });
984
- // These fused words will be added to the same segment entry below
985
- // (they share the audio with the verse words)
986
- } else if (fusedWords.length > 0) {
987
- // Fused prefix without timestamps — static text fallback
988
- var fusedTxt = fusedWords.map(function(w) { return w.textContent; }).join(' ').trim();
989
- if (fusedTxt) {
990
- if (pendingSpecial) {
991
- pendingSpecial += ' ' + fusedTxt;
992
- } else {
993
- pendingSpecial = fusedTxt;
994
- }
995
- }
996
- }
997
-
998
- var sourceWords = Array.from(textEl.querySelectorAll('.word'));
999
- var segWordEls = []; // unified DOM elements this segment animates
1000
- var segWordTimings = []; // {start, end} from source card
1001
- var segCharTimings = []; // [{start, end, parentWordIdx}, ...]
1002
- var unifiedCharEls = {}; // wordIdx -> Array of .char elements in unified DOM
1003
-
1004
- // Iterate source childNodes to copy words + verse markers, deduplicating by data-pos
1005
- var childNodes = Array.from(textEl.childNodes);
1006
- var lastWasNew = false; // track if the last word was newly appended
1007
- childNodes.forEach(function(node) {
1008
- if (node.nodeType === Node.ELEMENT_NODE && node.classList && node.classList.contains('word')) {
1009
- var pos = node.dataset.pos;
1010
- if (!pos) return; // skip words without data-pos
1011
- if (pos.indexOf(':0:0:') !== -1) return; // skip fused prefix words (handled above)
1012
- if (renderedPositions[pos]) {
1013
- // Word already in unified text — reuse existing element
1014
- segWordEls.push(renderedPositions[pos]);
1015
- lastWasNew = false;
1016
- } else {
1017
- // New word — clone and append
1018
- var clone = node.cloneNode(true);
1019
- // Detect surah change for separator
1020
- var posParts = pos.split(':');
1021
- if (posParts.length >= 1 && posParts[0]) {
1022
- var wordSurah = posParts[0];
1023
- if (wordSurah !== lastSurah) {
1024
- var sepDiv = document.createElement('div');
1025
- sepDiv.className = 'mega-surah-separator';
1026
- if (lastSurah === null) sepDiv.style.borderTop = 'none';
1027
- var ligKey = 'surah-' + wordSurah;
1028
- sepDiv.textContent = surahLigatures[ligKey] || wordSurah;
1029
- // Insert before any trailing special line so separator comes before basmala
1030
- var trailingSpecial = frag.lastChild;
1031
- if (trailingSpecial && trailingSpecial.classList && trailingSpecial.classList.contains('mega-special-line')) {
1032
- frag.insertBefore(sepDiv, trailingSpecial);
1033
- } else {
1034
- frag.appendChild(sepDiv);
1035
- }
1036
- }
1037
- lastSurah = wordSurah;
1038
- }
1039
- // Flush buffered special text after separator, before first word
1040
- flushPendingSpecial();
1041
- frag.appendChild(document.createTextNode(' '));
1042
- frag.appendChild(clone);
1043
- if (pos) {
1044
- renderedPositions[pos] = clone;
1045
- }
1046
- segWordEls.push(clone);
1047
- lastWasNew = true;
1048
- }
1049
- // Read timing from source word
1050
- segWordTimings.push({
1051
- start: parseFloat(node.dataset.start) || 0,
1052
- end: parseFloat(node.dataset.end) || 0
1053
- });
1054
- // Read char timings from source and cache unified char elements
1055
- var wordIdx = segWordEls.length - 1;
1056
- var pos2 = node.dataset.pos;
1057
- if (pos2 && renderedCharEls[pos2]) {
1058
- unifiedCharEls[wordIdx] = renderedCharEls[pos2];
1059
- } else {
1060
- var chars = Array.from(segWordEls[wordIdx].children);
1061
- unifiedCharEls[wordIdx] = chars;
1062
- if (pos2) renderedCharEls[pos2] = chars;
1063
- }
1064
- var srcChars = Array.from(node.children);
1065
- srcChars.forEach(function(srcChar) {
1066
- segCharTimings.push({
1067
- start: parseFloat(srcChar.dataset.start) || 0,
1068
- end: parseFloat(srcChar.dataset.end) || 0,
1069
- parentWordIdx: wordIdx
1070
- });
1071
- });
1072
- } else if (node.nodeType === Node.ELEMENT_NODE && lastWasNew) {
1073
- // Verse marker or other non-word element — append only if preceding word was new
1074
- var markerText = node.textContent || '';
1075
- var markerKey = (lastSurah || '') + ':' + markerText.trim();
1076
- if (!markerText.trim() || !renderedMarkers[markerKey]) {
1077
- frag.appendChild(document.createTextNode(' '));
1078
- var markerSpan = document.createElement('span');
1079
- markerSpan.className = 'verse-marker';
1080
- markerSpan.title = 'Jump to this verse';
1081
- markerSpan.appendChild(node.cloneNode(true));
1082
- // Extract verse from preceding word's data-pos (surah:ayah:word)
1083
- var lastWordPos = segWordEls.length > 0 ? (segWordEls[segWordEls.length - 1].dataset.pos || '') : '';
1084
- var posPartsM = lastWordPos.split(':');
1085
- if (posPartsM.length >= 2) markerSpan.dataset.verse = posPartsM[0] + ':' + posPartsM[1];
1086
- frag.appendChild(markerSpan);
1087
- if (markerText.trim()) renderedMarkers[markerKey] = true;
1088
- }
1089
- } else if (node.nodeType === Node.TEXT_NODE && lastWasNew) {
1090
- // Verse markers are plain text nodes (e.g. ۝٢٥٥), not elements
1091
- var txt = node.textContent || '';
1092
- if (txt.trim()) {
1093
- var markerKey = (lastSurah || '') + ':' + txt.trim();
1094
- if (!renderedMarkers[markerKey]) {
1095
- frag.appendChild(document.createTextNode(' '));
1096
- var markerSpan2 = document.createElement('span');
1097
- markerSpan2.className = 'verse-marker';
1098
- markerSpan2.title = 'Jump to this verse';
1099
- markerSpan2.textContent = txt.trim();
1100
- var lastWordPos2 = segWordEls.length > 0 ? (segWordEls[segWordEls.length - 1].dataset.pos || '') : '';
1101
- var posPartsM2 = lastWordPos2.split(':');
1102
- if (posPartsM2.length >= 2) markerSpan2.dataset.verse = posPartsM2[0] + ':' + posPartsM2[1];
1103
- frag.appendChild(markerSpan2);
1104
- renderedMarkers[markerKey] = true;
1105
- }
1106
- }
1107
- }
1108
- });
1109
-
1110
- if (segWordEls.length === 0) return;
1111
-
1112
- // Build caches: pair source timings with unified DOM elements
1113
- var cacheWords = segWordEls.map(function(el, j) {
1114
- return {
1115
- el: el,
1116
- start: segWordTimings[j].start,
1117
- end: segWordTimings[j].end
1118
- };
1119
- });
1120
-
1121
- // Build char cache using pre-collected unified char elements
1122
- var cacheChars = [];
1123
- var charCountPerWord = {};
1124
- segCharTimings.forEach(function(ct) {
1125
- var wIdx = ct.parentWordIdx;
1126
- if (charCountPerWord[wIdx] === undefined) charCountPerWord[wIdx] = 0;
1127
- var charIdxInWord = charCountPerWord[wIdx]++;
1128
- var unifiedChars = unifiedCharEls[wIdx];
1129
- if (unifiedChars && charIdxInWord < unifiedChars.length) {
1130
- cacheChars.push({
1131
- el: unifiedChars[charIdxInWord],
1132
- start: ct.start,
1133
- end: ct.end
1134
- });
1135
- }
1136
- });
1137
-
1138
- // Prepend animated fused prefix words/chars (same audio segment)
1139
- if (typeof fusedWordEls !== 'undefined' && fusedWordEls.length > 0 && fusedHasTimestamps) {
1140
- var fusedCacheWords = fusedWordEls.map(function(el, j) {
1141
- return { el: el, start: fusedWordTimings[j].start, end: fusedWordTimings[j].end };
1142
- });
1143
- var fusedCacheChars = [];
1144
- var fusedCharCount = {};
1145
- fusedCharTimings.forEach(function(ct) {
1146
- var wIdx = ct.parentWordIdx;
1147
- if (fusedCharCount[wIdx] === undefined) fusedCharCount[wIdx] = 0;
1148
- var charIdx = fusedCharCount[wIdx]++;
1149
- var chars = fusedCharEls[wIdx];
1150
- if (chars && charIdx < chars.length) {
1151
- fusedCacheChars.push({ el: chars[charIdx], start: ct.start, end: ct.end });
1152
- }
1153
- });
1154
- cacheWords = fusedCacheWords.concat(cacheWords);
1155
- cacheChars = fusedCacheChars.concat(cacheChars);
1156
- segWordEls = fusedWordEls.concat(segWordEls);
1157
- // Reset fused state for next card
1158
- fusedWordEls = []; fusedHasTimestamps = false;
1159
- }
1160
-
1161
- // Build group index for segment caches (for character grouping in animations)
1162
- buildGroupIndex(cacheWords);
1163
- buildGroupIndex(cacheChars);
1164
-
1165
- segments.push({
1166
- startTime: segStartTime,
1167
- endTime: segEndTime,
1168
- cacheWords: cacheWords,
1169
- cacheChars: cacheChars,
1170
- wordEls: segWordEls
1171
- });
1172
- });
1173
-
1174
- // Flush any remaining buffered special text
1175
- flushPendingSpecial();
1176
-
1177
- textFlow.appendChild(frag);
1178
- // Build unified caches so Window mode spans all segments.
1179
- // Deduplicate: each DOM element appears once (shared elements from
1180
- // repeated segments reuse the first segment's unified index).
1181
- var unifiedWords = [];
1182
- var unifiedChars = [];
1183
- var wordElToIdx = new Map();
1184
- var charElToIdx = new Map();
1185
- var elToFirstSeg = new Map();
1186
- segments.forEach(function(seg, sIdx) {
1187
- seg.sharedEls = new Set();
1188
- seg.unifiedWordMap = [];
1189
- seg.unifiedCharMap = [];
1190
- for (var i = 0; i < seg.cacheWords.length; i++) {
1191
- var el = seg.cacheWords[i].el;
1192
- if (wordElToIdx.has(el)) {
1193
- seg.unifiedWordMap.push(wordElToIdx.get(el));
1194
- seg.sharedEls.add(el);
1195
- segments[elToFirstSeg.get(el)].sharedEls.add(el);
1196
- } else {
1197
- var idx = unifiedWords.length;
1198
- wordElToIdx.set(el, idx);
1199
- elToFirstSeg.set(el, sIdx);
1200
- seg.unifiedWordMap.push(idx);
1201
- unifiedWords.push(seg.cacheWords[i]);
1202
- }
1203
- }
1204
- for (var i = 0; i < seg.cacheChars.length; i++) {
1205
- var el = seg.cacheChars[i].el;
1206
- if (charElToIdx.has(el)) {
1207
- seg.unifiedCharMap.push(charElToIdx.get(el));
1208
- } else {
1209
- var idx = unifiedChars.length;
1210
- charElToIdx.set(el, idx);
1211
- seg.unifiedCharMap.push(idx);
1212
- unifiedChars.push(seg.cacheChars[i]);
1213
- }
1214
- }
1215
- });
1216
- // Tag each word/char element with its segment index for click-to-seek
1217
- segments.forEach(function(seg, sIdx) {
1218
- seg.cacheWords.forEach(function(c) { c.el.dataset.segIdx = sIdx; });
1219
- seg.cacheChars.forEach(function(c) { c.el.dataset.segIdx = sIdx; });
1220
- });
1221
- // Build group index for unified caches (for character grouping across all segments)
1222
- buildGroupIndex(unifiedWords);
1223
- buildGroupIndex(unifiedChars);
1224
-
1225
- animateAllState.segments = segments;
1226
- animateAllState.unifiedCacheWords = unifiedWords;
1227
- animateAllState.unifiedCacheChars = unifiedChars;
1228
- animateAllState.textFlow = textFlow;
1229
- animDebug('MEGA', 'buildMegaCard: ' + segments.length + ' segments, unifiedWords=' + unifiedWords.length + ' unifiedChars=' + unifiedChars.length);
1230
- dumpCacheTimestamps(unifiedWords, 'Unified Words');
1231
- dumpCacheTimestamps(unifiedChars, 'Unified Chars');
1232
- mega.appendChild(textFlow);
1233
-
1234
- // Build top bar with Exit button (placed outside mega card)
1235
- var topBar = document.createElement('div');
1236
- topBar.className = 'mega-top-bar';
1237
- var exitBtn = document.createElement('button');
1238
- exitBtn.className = 'mega-exit-btn';
1239
- exitBtn.textContent = 'Exit';
1240
- exitBtn.title = 'Exit and return to individual segments';
1241
- topBar.appendChild(exitBtn);
1242
- // Speed dropdown
1243
- var speedSelect = document.createElement('select');
1244
- speedSelect.className = 'mega-speed-select';
1245
- speedSelect.title = 'Playback speed';
1246
- [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2].forEach(function(r) {
1247
- var opt = document.createElement('option');
1248
- opt.value = r;
1249
- opt.textContent = r + 'x';
1250
- if (r === (window.ANIM_PLAYBACK_RATE || 1)) opt.selected = true;
1251
- speedSelect.appendChild(opt);
1252
- });
1253
- speedSelect.addEventListener('change', function() {
1254
- var rate = parseFloat(speedSelect.value);
1255
- window.ANIM_PLAYBACK_RATE = rate;
1256
- if (animateAllState.unifiedAudio) {
1257
- animateAllState.unifiedAudio.playbackRate = rate;
1258
- }
1259
- saveAnimSettings();
1260
- });
1261
- topBar.appendChild(speedSelect);
1262
- return {mega: mega, topBar: topBar, unifiedAudio: unifiedAudio};
1263
- }
1264
-
1265
- // Reset highlight state for backward transitions (cache-based, no DOM queries)
1266
- function resetHighlightsFrom(segIdx) {
1267
- for (var s = segIdx; s < animateAllState.segments.length; s++) {
1268
- var seg = animateAllState.segments[s];
1269
- seg.cacheWords.forEach(function(c) {
1270
- c.el.classList.remove('active', 'reached');
1271
- c.el.style.removeProperty('opacity');
1272
- });
1273
- seg.cacheChars.forEach(function(c) {
1274
- c.el.classList.remove('active', 'reached');
1275
- c.el.style.removeProperty('opacity');
1276
- });
1277
- }
1278
- // Re-apply 'reached' to shared elements belonging to completed earlier segments
1279
- for (var s = 0; s < segIdx && s <= animateAllState.completedIdx; s++) {
1280
- var prev = animateAllState.segments[s];
1281
- if (!prev.sharedEls || prev.sharedEls.size === 0) continue;
1282
- prev.cacheWords.forEach(function(c) {
1283
- if (prev.sharedEls.has(c.el)) c.el.classList.add('reached');
1284
- });
1285
- prev.cacheChars.forEach(function(c) {
1286
- if (prev.sharedEls.has(c.el)) c.el.classList.add('reached');
1287
- });
1288
- }
1289
- }
1290
-
1291
- function startSegmentTick(seg, audio, segStartTime, segEndTime) {
1292
- var lastWordIdx = -1;
1293
- var lastGranularity = window.ANIM_GRANULARITY;
1294
- var lastOpacityPrev = window.ANIM_OPACITY_PREV;
1295
- var lastSeenVersion = window._windowSettingsVersion;
1296
- var segIdx = animateAllState.currentIdx;
1297
- animDebug('MEGA', 'startSegmentTick seg=' + segIdx + ' cacheWords=' + seg.cacheWords.length + ' cacheChars=' + seg.cacheChars.length + ' bounds=[' + segStartTime.toFixed(3) + ',' + segEndTime.toFixed(3) + ']');
1298
- function tick() {
1299
- if (audio.paused) return;
1300
- var currentTime = audio.currentTime;
1301
-
1302
- // Check segment end boundary (unified audio spans all segments)
1303
- if (currentTime >= segEndTime) {
1304
- onAnimateAllSegmentEnd();
1305
- return;
1306
- }
1307
-
1308
- var cache = window.ANIM_GRANULARITY === 'Characters' ? seg.cacheChars : seg.cacheWords;
1309
- if (window.ANIM_GRANULARITY !== lastGranularity) {
1310
- animDebug('MEGA', 'Granularity changed: ' + lastGranularity + ' -> ' + window.ANIM_GRANULARITY);
1311
- // Granularity changed mid-animation: reset this segment's elements
1312
- seg.wordEls.forEach(function(w) {
1313
- w.classList.remove('active');
1314
- w.style.removeProperty('opacity');
1315
- w.querySelectorAll('.char').forEach(function(c) {
1316
- c.classList.remove('active');
1317
- c.style.removeProperty('opacity');
1318
- });
1319
- });
1320
- // Clear inline opacity across all segments (previous segments may have inline opacity)
1321
- animateAllState.unifiedCacheWords.forEach(function(c) { c.el.style.removeProperty('opacity'); });
1322
- animateAllState.unifiedCacheChars.forEach(function(c) { c.el.style.removeProperty('opacity'); });
1323
- lastWordIdx = -1;
1324
- lastGranularity = window.ANIM_GRANULARITY;
1325
- }
1326
-
1327
- // Mode changed mid-animation — refresh all reached words with new opacity
1328
- if (window.ANIM_OPACITY_PREV !== lastOpacityPrev) {
1329
- animDebug('MEGA', 'Mode changed: prevOp ' + lastOpacityPrev + ' -> ' + window.ANIM_OPACITY_PREV);
1330
- animateAllState.unifiedCacheWords.forEach(function(c) {
1331
- if (c.el.classList.contains('reached')) {
1332
- if (window.ANIM_OPACITY_PREV >= 1) {
1333
- c.el.style.removeProperty('opacity');
1334
- } else {
1335
- c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
1336
- }
1337
- }
1338
- });
1339
- animateAllState.unifiedCacheChars.forEach(function(c) {
1340
- if (c.el.classList.contains('reached')) {
1341
- if (window.ANIM_OPACITY_PREV >= 1) {
1342
- c.el.style.removeProperty('opacity');
1343
- } else {
1344
- c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
1345
- }
1346
- }
1347
- });
1348
- lastOpacityPrev = window.ANIM_OPACITY_PREV;
1349
- }
1350
-
1351
- // Slider settings changed mid-animation — reapply window opacity
1352
- if (window._windowSettingsVersion !== lastSeenVersion) {
1353
- if (lastWordIdx >= 0) {
1354
- var wc = window.ANIM_GRANULARITY === 'Characters'
1355
- ? animateAllState.unifiedCacheChars : animateAllState.unifiedCacheWords;
1356
- var map = window.ANIM_GRANULARITY === 'Characters'
1357
- ? seg.unifiedCharMap : seg.unifiedWordMap;
1358
- if (map[lastWordIdx] !== undefined) {
1359
- applyWindowOpacity(wc, map[lastWordIdx], map[lastWordIdx]);
1360
- }
1361
- }
1362
- lastSeenVersion = window._windowSettingsVersion;
1363
- }
1364
-
1365
- var newWordIdx = -1;
1366
- var searchPath = '';
1367
- // Fast path: check current word, then next (covers ~99% of frames)
1368
- if (lastWordIdx >= 0 && lastWordIdx < cache.length &&
1369
- currentTime >= cache[lastWordIdx].start && currentTime < cache[lastWordIdx].end) {
1370
- newWordIdx = lastWordIdx;
1371
- searchPath = 'same';
1372
- } else if (lastWordIdx + 1 < cache.length &&
1373
- currentTime >= cache[lastWordIdx + 1].start && currentTime < cache[lastWordIdx + 1].end) {
1374
- newWordIdx = lastWordIdx + 1;
1375
- searchPath = 'next';
1376
- } else {
1377
- // Fallback: full scan (seeking, granularity switch, etc.)
1378
- searchPath = 'scan';
1379
- for (var i = 0; i < cache.length; i++) {
1380
- if (currentTime >= cache[i].start && currentTime < cache[i].end) {
1381
- newWordIdx = i;
1382
- break;
1383
- }
1384
- }
1385
- // Clamp to last word when past its end but audio hasn't ended yet
1386
- if (newWordIdx === -1 && cache.length > 0 && currentTime >= cache[cache.length - 1].start) {
1387
- newWordIdx = cache.length - 1;
1388
- searchPath = 'clamp';
1389
- }
1390
- }
1391
- if (newWordIdx !== lastWordIdx) {
1392
- var newText = newWordIdx >= 0 ? cache[newWordIdx].el.textContent.substring(0, 15) : '-';
1393
- animDebug('MEGA', 'seg=' + segIdx + ' idx: ' + lastWordIdx + ' -> ' + newWordIdx + ' (' + searchPath + ') t=' + currentTime.toFixed(3) + ' "' + newText + '"');
1394
- if (newWordIdx === -1 && cache.length > 0) {
1395
- var first = cache[0];
1396
- var last = cache[cache.length - 1];
1397
- animDebug('MEGA', ' NO MATCH: t=' + currentTime.toFixed(3) + ' cache[0]=[' + first.start.toFixed(3) + ',' + first.end.toFixed(3) + '] cache[' + (cache.length-1) + ']=[' + last.start.toFixed(3) + ',' + last.end.toFixed(3) + ']');
1398
- }
1399
- if (lastWordIdx >= 0 && lastWordIdx < cache.length) {
1400
- applyClassToGroup(cache, lastWordIdx, 'active', false);
1401
- applyClassToGroup(cache, lastWordIdx, 'reached', true);
1402
- }
1403
- if (newWordIdx >= 0) {
1404
- applyClassToGroup(cache, newWordIdx, 'active', true);
1405
- if (lastWordIdx === -1) {
1406
- // First highlight — catch up any skipped words (with group support)
1407
- for (var j = 0; j < newWordIdx; j++) {
1408
- applyClassToGroup(cache, j, 'reached', true);
1409
- }
1410
- }
1411
- // auto-scroll disabled — causes jank with frequent word changes
1412
- }
1413
- if (newWordIdx >= 0) {
1414
- var wc = window.ANIM_GRANULARITY === 'Characters'
1415
- ? animateAllState.unifiedCacheChars : animateAllState.unifiedCacheWords;
1416
- var map = window.ANIM_GRANULARITY === 'Characters' ? seg.unifiedCharMap : seg.unifiedWordMap;
1417
- applyWindowOpacity(wc, map[newWordIdx],
1418
- lastWordIdx >= 0 ? map[lastWordIdx] : -1);
1419
- }
1420
- lastWordIdx = newWordIdx;
1421
- }
1422
- }
1423
- if (animateAllState.rafId) cancelAnimationFrame(animateAllState.rafId);
1424
- function rafLoop() {
1425
- tick();
1426
- // Keep looping while animation is active AND still on the same segment.
1427
- // When tick() triggers a segment transition, a new RAF loop is started for
1428
- // the new segment - this old loop must stop to avoid duplicate transitions.
1429
- if (animateAllState.active && !audio.paused && animateAllState.currentIdx === segIdx) {
1430
- animateAllState.rafId = requestAnimationFrame(rafLoop);
1431
- }
1432
- }
1433
- animateAllState.rafId = requestAnimationFrame(rafLoop);
1434
- }
1435
-
1436
- function animateSegment(idx, seekTime) {
1437
- var seg = animateAllState.segments[idx];
1438
- if (!seg) return;
1439
- animateAllState.currentIdx = idx;
1440
- // Re-tag shared elements so click-to-seek targets this segment during playback
1441
- seg.cacheWords.forEach(function(c) { c.el.dataset.segIdx = idx; });
1442
- seg.cacheChars.forEach(function(c) { c.el.dataset.segIdx = idx; });
1443
- var audio = animateAllState.unifiedAudio;
1444
- // Apply window engine class to mega card
1445
- var mega = animateAllState.megaCard;
1446
- mega.classList.add('anim-window');
1447
- if (window.ANIM_GRANULARITY === 'Characters') {
1448
- mega.classList.add('anim-chars');
1449
- }
1450
- // Detect backward transition: jumping to a segment at or before the last completed one
1451
- if (idx <= animateAllState.completedIdx) {
1452
- resetHighlightsFrom(idx);
1453
- // Reset completedIdx so forward progress from here is tracked correctly
1454
- animateAllState.completedIdx = idx - 1;
1455
- }
1456
- // Determine seek position: use provided seekTime or default to segment start
1457
- var targetTime = (seekTime !== undefined) ? seekTime : seg.startTime;
1458
- // Start playback and polling with segment boundaries
1459
- startSegmentTick(seg, audio, seg.startTime, seg.endTime);
1460
- audio.currentTime = targetTime;
1461
- audio.playbackRate = window.ANIM_PLAYBACK_RATE || 1;
1462
- audio.play();
1463
- // No per-segment preloading needed — unified audio already loaded
1464
- }
1465
-
1466
- function seekToWord(wordEl) {
1467
- if (!animateAllState.active) return;
1468
- var cache = window.ANIM_GRANULARITY === 'Characters'
1469
- ? animateAllState.unifiedCacheChars : animateAllState.unifiedCacheWords;
1470
- // Find unified index and start time for the clicked word
1471
- var targetIdx = -1;
1472
- var wordStartTime = 0;
1473
- // For char granularity, find the first char of the clicked word
1474
- if (window.ANIM_GRANULARITY === 'Characters') {
1475
- for (var i = 0; i < cache.length; i++) {
1476
- if (cache[i].el.parentElement === wordEl || cache[i].el === wordEl) {
1477
- targetIdx = i;
1478
- wordStartTime = cache[i].start;
1479
- break;
1480
- }
1481
- }
1482
- } else {
1483
- for (var i = 0; i < cache.length; i++) {
1484
- if (cache[i].el === wordEl) {
1485
- targetIdx = i;
1486
- wordStartTime = cache[i].start;
1487
- break;
1488
- }
1489
- }
1490
- }
1491
- if (targetIdx < 0) return;
1492
- // Derive segment from word timing (more reliable than data-segIdx for shared elements)
1493
- var segIdx = 0;
1494
- for (var s = 0; s < animateAllState.segments.length; s++) {
1495
- var seg = animateAllState.segments[s];
1496
- if (wordStartTime >= seg.startTime && wordStartTime < seg.endTime) {
1497
- segIdx = s;
1498
- break;
1499
- }
1500
- }
1501
- // Reset window cache to force full reapplication (not fast path)
1502
- window._windowActiveIdx = -1;
1503
- window._windowLastPcAll = false;
1504
- window._windowLastAcAll = false;
1505
- // Update highlight state: words before target = reached, target onward = clean
1506
- for (var i = 0; i < cache.length; i++) {
1507
- cache[i].el.classList.remove('active');
1508
- cache[i].el.style.removeProperty('opacity');
1509
- if (i < targetIdx) {
1510
- cache[i].el.classList.add('reached');
1511
- } else {
1512
- cache[i].el.classList.remove('reached');
1513
- }
1514
- }
1515
- // Also sync the other cache (chars if in word mode, words if in char mode)
1516
- var otherCache = window.ANIM_GRANULARITY === 'Characters'
1517
- ? animateAllState.unifiedCacheWords : animateAllState.unifiedCacheChars;
1518
- for (var i = 0; i < otherCache.length; i++) {
1519
- otherCache[i].el.classList.remove('active');
1520
- otherCache[i].el.style.removeProperty('opacity');
1521
- }
1522
- // Re-apply window opacity immediately for the new position
1523
- applyWindowOpacity(cache, targetIdx, -1);
1524
- // Switch segment or seek within current (unified audio)
1525
- var audio = animateAllState.unifiedAudio;
1526
- if (animateAllState.rafId) {
1527
- cancelAnimationFrame(animateAllState.rafId);
1528
- animateAllState.rafId = null;
1529
- }
1530
- if (segIdx !== animateAllState.currentIdx) {
1531
- // Different segment — restart tick loop with new segment boundaries
1532
- animateSegment(segIdx, wordStartTime);
1533
- } else {
1534
- // Same segment — restart tick loop at new position for clean state
1535
- audio.currentTime = wordStartTime;
1536
- var seg = animateAllState.segments[segIdx];
1537
- var segStartTime = segIdx > 0 ? animateAllState.segments[segIdx - 1].endTime : 0;
1538
- startSegmentTick(seg, audio, segStartTime, seg.endTime);
1539
- }
1540
- }
1541
-
1542
- function seekToVerseMarker(markerEl) {
1543
- if (!animateAllState.active) return;
1544
- var verse = markerEl.dataset.verse; // e.g. "2:255"
1545
- if (!verse) return;
1546
- var prefix = verse + ':';
1547
- // Find first word in unified cache whose data-pos starts with this verse
1548
- var cache = animateAllState.unifiedCacheWords;
1549
- for (var i = 0; i < cache.length; i++) {
1550
- var pos = cache[i].el.dataset.pos || '';
1551
- if (pos === verse || pos.indexOf(prefix) === 0) {
1552
- seekToWord(cache[i].el);
1553
- return;
1554
- }
1555
- }
1556
- }
1557
-
1558
- function onAnimateAllSegmentEnd() {
1559
- if (!animateAllState.active) return;
1560
- var seg = animateAllState.segments[animateAllState.currentIdx];
1561
- if (seg) {
1562
- // Mark all words/chars in finished segment as reached.
1563
- // Use mode's prev_opacity for completed elements.
1564
- seg.cacheWords.forEach(function(c) {
1565
- if (c.el.classList.contains('active')) {
1566
- c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
1567
- }
1568
- c.el.classList.remove('active');
1569
- c.el.classList.add('reached');
1570
- });
1571
- seg.cacheChars.forEach(function(c) {
1572
- if (c.el.classList.contains('active')) {
1573
- c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
1574
- }
1575
- c.el.classList.remove('active');
1576
- c.el.classList.add('reached');
1577
- });
1578
- animateAllState.completedIdx = animateAllState.currentIdx;
1579
- }
1580
- var nextIdx = animateAllState.currentIdx + 1;
1581
- if (nextIdx < animateAllState.segments.length) {
1582
- // Unified audio continues playing — just switch segment tracking
1583
- var nextSeg = animateAllState.segments[nextIdx];
1584
- animateAllState.currentIdx = nextIdx;
1585
- // Re-tag shared elements for the new segment
1586
- nextSeg.cacheWords.forEach(function(c) { c.el.dataset.segIdx = nextIdx; });
1587
- nextSeg.cacheChars.forEach(function(c) { c.el.dataset.segIdx = nextIdx; });
1588
- // Restart tick loop with new segment boundaries
1589
- startSegmentTick(nextSeg, animateAllState.unifiedAudio, nextSeg.startTime, nextSeg.endTime);
1590
- } else {
1591
- // All segments done — tear down after brief delay
1592
- setTimeout(function() { stopAnimateAll(); }, 500);
1593
- }
1594
- }
1595
-
1596
- // Pause: keep mega card and position, just stop playback
1597
- function pauseAnimateAll() {
1598
- if (animateAllState.rafId) {
1599
- cancelAnimationFrame(animateAllState.rafId);
1600
- animateAllState.rafId = null;
1601
- }
1602
- if (animateAllState.unifiedAudio) animateAllState.unifiedAudio.pause();
1603
- animateAllState.active = false;
1604
-
1605
- // Fade last active word to mode's prev_opacity
1606
- var seg = animateAllState.segments[animateAllState.currentIdx];
1607
- if (seg) {
1608
- var cache = window.ANIM_GRANULARITY === 'Characters' ? seg.cacheChars : seg.cacheWords;
1609
- cache.forEach(function(c) {
1610
- if (c.el.classList.contains('active')) {
1611
- c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
1612
- }
1613
- });
1614
- }
1615
-
1616
- if (animateAllState.btn) {
1617
- animateAllState.btn.classList.remove('active');
1618
- animateAllState.btn.textContent = 'Resume';
1619
- }
1620
- }
1621
-
1622
- // Full teardown: remove mega card, restore segment cards
1623
- function stopAnimateAll() {
1624
- if (animateAllState.rafId) {
1625
- cancelAnimationFrame(animateAllState.rafId);
1626
- animateAllState.rafId = null;
1627
- }
1628
- if (animateAllState.unifiedAudio) animateAllState.unifiedAudio.pause();
1629
- // Move Stop/Resume button back to its original parent before removing mega card
1630
- if (animateAllState.btn && animateAllState.btnParent) {
1631
- animateAllState.btnParent.appendChild(animateAllState.btn);
1632
- }
1633
- // Remove tip callout, top bar, and mega card
1634
- var tipEl = document.querySelector('.mega-tip');
1635
- if (tipEl) tipEl.parentNode.removeChild(tipEl);
1636
- if (animateAllState.topBar && animateAllState.topBar.parentNode) {
1637
- animateAllState.topBar.parentNode.removeChild(animateAllState.topBar);
1638
- }
1639
- if (animateAllState.megaCard && animateAllState.megaCard.parentNode) {
1640
- animateAllState.megaCard.parentNode.removeChild(animateAllState.megaCard);
1641
- }
1642
- animateAllState.megaCard = null;
1643
- animateAllState.topBar = null;
1644
- animateAllState.textFlow = null;
1645
- // Unhide segment cards
1646
- document.querySelectorAll('.segment-card.hidden-for-mega').forEach(function(c) {
1647
- c.classList.remove('hidden-for-mega');
1648
- });
1649
- // Restore left column and right column sizing
1650
- var leftCol = document.getElementById('left-col');
1651
- if (leftCol) leftCol.style.display = '';
1652
- var mainRow = document.getElementById('main-row');
1653
- if (mainRow) {
1654
- var rightCol = mainRow.querySelector(':scope > div:last-child');
1655
- if (rightCol) rightCol.style.flexGrow = animateAllState.savedFlexGrow || '';
1656
- }
1657
- // Restore description and API accordion
1658
- if (mainRow) {
1659
- var sibling = mainRow.parentNode.firstElementChild;
1660
- while (sibling && sibling !== mainRow) {
1661
- sibling.style.display = '';
1662
- sibling = sibling.nextElementSibling;
1663
- }
1664
- }
1665
- // Restore header/summary text and action buttons
1666
- document.querySelectorAll('.segments-header, .segments-review-summary').forEach(function(el) {
1667
- el.style.display = '';
1668
- });
1669
- var actionRow = document.getElementById('action-btns-row');
1670
- if (actionRow) actionRow.style.display = '';
1671
- var tsRow = document.getElementById('ts-row');
1672
- if (tsRow) tsRow.style.display = '';
1673
- // Move animation settings accordion back to left column
1674
- var animAccordion = document.getElementById('anim-settings-accordion');
1675
- if (animAccordion && animateAllState.accordionParent) {
1676
- if (animateAllState.accordionNextSibling) {
1677
- animateAllState.accordionParent.insertBefore(animAccordion, animateAllState.accordionNextSibling);
1678
- } else {
1679
- animateAllState.accordionParent.appendChild(animAccordion);
1680
- }
1681
- }
1682
- // Hide mega styling sliders (only shown in megacard view)
1683
- var megaStylingRow = document.getElementById('mega-styling-row');
1684
- if (megaStylingRow) megaStylingRow.style.display = 'none';
1685
- // Reset button
1686
- if (animateAllState.btn) {
1687
- animateAllState.btn.classList.remove('active');
1688
- animateAllState.btn.textContent = 'Animate All';
1689
- }
1690
- animateAllState.active = false;
1691
- animateAllState.completedIdx = -1;
1692
- animateAllState.segments = [];
1693
- animateAllState.unifiedCacheWords = [];
1694
- animateAllState.unifiedCacheChars = [];
1695
- animateAllState.unifiedAudio = null;
1696
- animateAllState.megaCard = null;
1697
- animateAllState.textFlow = null;
1698
- window.ANIM_PLAYBACK_RATE = 1;
1699
- }
1700
-
1701
- // Resume from paused position
1702
- function resumeAnimateAll() {
1703
- animateAllState.active = true;
1704
- if (animateAllState.btn) {
1705
- animateAllState.btn.classList.add('active');
1706
- animateAllState.btn.textContent = 'Stop';
1707
- }
1708
- var seg = animateAllState.segments[animateAllState.currentIdx];
1709
- if (!seg) return;
1710
- var audio = animateAllState.unifiedAudio;
1711
- // Re-apply animation classes
1712
- var mega = animateAllState.megaCard;
1713
- mega.classList.add('anim-window');
1714
- if (window.ANIM_GRANULARITY === 'Characters') {
1715
- mega.classList.add('anim-chars');
1716
- }
1717
- // Restart polling from current audio position
1718
- startSegmentTick(seg, audio, seg.startTime, seg.endTime);
1719
- audio.playbackRate = window.ANIM_PLAYBACK_RATE || 1;
1720
- audio.play();
1721
- }
1722
-
1723
- function toggleAnimateAll(btn) {
1724
- // Currently playing → pause (keep mega card)
1725
- if (animateAllState.active) {
1726
- pauseAnimateAll();
1727
- return;
1728
- }
1729
- // Paused with mega card still in live DOM → resume
1730
- if (animateAllState.megaCard && document.contains(animateAllState.megaCard)) {
1731
- resumeAnimateAll();
1732
- return;
1733
- }
1734
- // Stale mega card (orphaned by Gradio HTML update) → reset state
1735
- if (animateAllState.megaCard) {
1736
- animateAllState.megaCard = null;
1737
- animateAllState.textFlow = null;
1738
- animateAllState.segments = [];
1739
- animateAllState.unifiedCacheWords = [];
1740
- animateAllState.unifiedCacheChars = [];
1741
- animateAllState.unifiedAudio = null;
1742
- animateAllState.completedIdx = -1;
1743
- animateAllState.active = false;
1744
- }
1745
- // Fresh start
1746
- document.querySelectorAll('.animate-btn.active').forEach(function(b) {
1747
- toggleAnimation(b);
1748
- });
1749
- var result = buildMegaCard();
1750
- if (!result || animateAllState.segments.length === 0) return;
1751
- if (!result.unifiedAudio) {
1752
- console.error('Animate All: unified audio not available');
1753
- return;
1754
- }
1755
- var mega = result.mega;
1756
- var topBar = result.topBar;
1757
- animateAllState.megaCard = mega;
1758
- animateAllState.topBar = topBar;
1759
- animateAllState.unifiedAudio = result.unifiedAudio;
1760
- animateAllState.btn = btn;
1761
- animateAllState.active = true;
1762
- // Hide individual segment cards
1763
- document.querySelectorAll('.segments-container .segment-card').forEach(function(c) {
1764
- c.classList.add('hidden-for-mega');
1765
- });
1766
- // Hide left column and expand right column to full width
1767
- var leftCol = document.getElementById('left-col');
1768
- if (leftCol) leftCol.style.display = 'none';
1769
- var mainRow = document.getElementById('main-row');
1770
- if (mainRow) {
1771
- var rightCol = mainRow.querySelector(':scope > div:last-child');
1772
- if (rightCol) {
1773
- animateAllState.savedFlexGrow = rightCol.style.flexGrow;
1774
- rightCol.style.flexGrow = '1';
1775
- }
1776
- }
1777
- // Hide description and API accordion (everything before main-row)
1778
- if (mainRow) {
1779
- var sibling = mainRow.parentNode.firstElementChild;
1780
- while (sibling && sibling !== mainRow) {
1781
- sibling.style.display = 'none';
1782
- sibling = sibling.nextElementSibling;
1783
- }
1784
- }
1785
- // Hide header/summary text and action buttons
1786
- document.querySelectorAll('.segments-header, .segments-review-summary').forEach(function(el) {
1787
- el.style.display = 'none';
1788
- });
1789
- var actionRow = document.getElementById('action-btns-row');
1790
- if (actionRow) actionRow.style.display = 'none';
1791
- var tsRow = document.getElementById('ts-row');
1792
- if (tsRow) tsRow.style.display = 'none';
1793
- // Move animation settings accordion above segments container
1794
- var animAccordion = document.getElementById('anim-settings-accordion');
1795
- if (animAccordion) {
1796
- animateAllState.accordionParent = animAccordion.parentNode;
1797
- animateAllState.accordionNextSibling = animAccordion.nextElementSibling;
1798
- var container = document.querySelector('.segments-container');
1799
- if (container) container.parentNode.insertBefore(animAccordion, container);
1800
- }
1801
- // Show mega styling sliders (hidden in normal card view)
1802
- var megaStylingRow = document.getElementById('mega-styling-row');
1803
- if (megaStylingRow) megaStylingRow.style.display = 'flex';
1804
- var container = document.querySelector('.segments-container');
1805
- // Add tip callout above the mega card
1806
- var tip = document.createElement('div');
1807
- tip.className = 'mega-tip';
1808
- tip.textContent = 'Tip: Click on any word to seek to it, or click a verse marker to jump to the start of that verse.';
1809
- container.appendChild(topBar);
1810
- container.appendChild(tip);
1811
- container.appendChild(mega);
1812
- // Append unified audio element to mega card (hidden)
1813
- if (animateAllState.unifiedAudio) {
1814
- mega.appendChild(animateAllState.unifiedAudio);
1815
- }
1816
- // Move Stop/Resume button into top bar (above mega card)
1817
- btn.classList.add('active');
1818
- btn.textContent = 'Stop';
1819
- animateAllState.btnParent = btn.parentNode;
1820
- topBar.appendChild(btn);
1821
- // Start animation from first segment
1822
- animateSegment(0);
1823
- }
1824
-
1825
- // Event delegation for Animate button clicks and click-to-seek
1826
- document.addEventListener('click', function(e) {
1827
- // Click-to-seek in Animate All mode
1828
- if (animateAllState.active && animateAllState.megaCard) {
1829
- var wordEl = e.target.closest('.word');
1830
- if (wordEl && animateAllState.megaCard.contains(wordEl)) {
1831
- seekToWord(wordEl);
1832
- return;
1833
- }
1834
- var markerEl = e.target.closest('.verse-marker');
1835
- if (markerEl && animateAllState.megaCard.contains(markerEl)) {
1836
- seekToVerseMarker(markerEl);
1837
- return;
1838
- }
1839
- }
1840
- if (e.target.matches('.play-btn')) {
1841
- var card = e.target.closest('.segment-card');
1842
- var audio = card && card.querySelector('audio');
1843
- if (audio) {
1844
- activateAudio(audio);
1845
- audio.play().catch(function(){});
1846
- }
1847
- }
1848
- if (e.target.matches('.animate-btn')) {
1849
- toggleAnimation(e.target);
1850
- }
1851
- if (e.target.matches('.animate-all-btn')) {
1852
- toggleAnimateAll(e.target);
1853
- }
1854
- if (e.target.matches('.mega-exit-btn')) {
1855
- stopAnimateAll();
1856
- }
1857
- });
1858
-
1859
- // Clear highlights when audio ends (with delay so last word is visible)
1860
- document.addEventListener('ended', function(e) {
1861
- if (e.target.tagName === 'AUDIO') {
1862
- var audio = e.target;
1863
- // If Animate All is active with unified audio, handle full recording end
1864
- if (animateAllState.active && audio === animateAllState.unifiedAudio) {
1865
- // Unified audio ended — entire recording finished
1866
- setTimeout(function() { stopAnimateAll(); }, 500);
1867
- return;
1868
- }
1869
- // Default per-card behavior
1870
- var card = audio.closest('.segment-card');
1871
- if (card) {
1872
- setTimeout(function() {
1873
- var btn = card.querySelector('.animate-btn');
1874
- if (btn && btn.classList.contains('active')) {
1875
- btn.classList.remove('active');
1876
- btn.textContent = 'Animate';
1877
- stopAnimation(audio, card);
1878
- }
1879
- }, 500);
1880
- }
1881
- }
1882
- }, true);
1883
-
1884
- // Prefetch next segment's audio when current segment starts playing
1885
- document.addEventListener('play', function(e) {
1886
- if (e.target.tagName === 'AUDIO') {
1887
- var card = e.target.closest('.segment-card');
1888
- if (!card) return;
1889
- var next = card.nextElementSibling;
1890
- if (next && next.classList.contains('segment-card')) {
1891
- var nextAudio = next.querySelector('audio');
1892
- if (nextAudio && !nextAudio.src && nextAudio.dataset.src) {
1893
- nextAudio.src = nextAudio.dataset.src;
1894
- nextAudio.preload = 'metadata';
1895
- }
1896
- }
1897
- }
1898
- }, true);
1899
- })();
1900
-
1901
- </script>
1902
- """
1903
-
1904
- js = js.replace('__SURAH_LIGATURES_JSON__', json.dumps(_SURAH_LIGATURES))
1905
 
1906
  with gr.Blocks(title="Quran Multi-Aligner", css=css, head=js, delete_cache=(DELETE_CACHE_FREQUENCY, DELETE_CACHE_AGE)) as app:
1907
  gr.Markdown("# 🎙️ Quran Multi-Aligner")
 
24
  LEFT_COLUMN_SCALE, RIGHT_COLUMN_SCALE,
25
  )
26
  from src.ui.styles import build_css
27
+ from src.ui.js_config import build_js_head
28
  from src.pipeline.process import (
29
  process_audio, resegment_audio,
30
  _retranscribe_wrapper, process_audio_json, save_json_export,
 
41
 
42
  css = build_css()
43
 
44
+ js = build_js_head(_SURAH_LIGATURES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  with gr.Blocks(title="Quran Multi-Aligner", css=css, head=js, delete_cache=(DELETE_CACHE_FREQUENCY, DELETE_CACHE_AGE)) as app:
47
  gr.Markdown("# 🎙️ Quran Multi-Aligner")
src/ui/js_config.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Python→JS config bridge — emits window.* globals and concatenates JS files."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from config import (
6
+ ANIM_PRESETS,
7
+ ANIM_WINDOW_PREV_MAX, ANIM_WINDOW_AFTER_MAX,
8
+ ANIM_WORD_COLOR,
9
+ MEGA_WORD_SPACING_DEFAULT, MEGA_TEXT_SIZE_DEFAULT, MEGA_LINE_SPACING_DEFAULT,
10
+ ANIM_DISPLAY_MODE_DEFAULT, ANIM_GRANULARITY_DEFAULT,
11
+ ANIM_OPACITY_PREV_DEFAULT, ANIM_OPACITY_AFTER_DEFAULT,
12
+ ANIM_WINDOW_PREV_DEFAULT, ANIM_WINDOW_AFTER_DEFAULT,
13
+ )
14
+
15
+ _STATIC_DIR = Path(__file__).parent / "static"
16
+
17
+
18
+ def build_js_head(surah_ligatures: dict) -> str:
19
+ """Return a <script> block with Python config globals and both JS files."""
20
+ config_lines = [
21
+ f"window.SURAH_LIGATURES = {json.dumps(surah_ligatures)};",
22
+ f"window.ANIM_PRESETS = {json.dumps(ANIM_PRESETS)};",
23
+ f"window.ANIM_WINDOW_PREV_MAX = {ANIM_WINDOW_PREV_MAX};",
24
+ f"window.ANIM_WINDOW_AFTER_MAX = {ANIM_WINDOW_AFTER_MAX};",
25
+ f"window.ANIM_WORD_COLOR_DEFAULT = {json.dumps(ANIM_WORD_COLOR)};",
26
+ f"window.MEGA_WORD_SPACING_DEFAULT = {MEGA_WORD_SPACING_DEFAULT};",
27
+ f"window.MEGA_TEXT_SIZE_DEFAULT = {MEGA_TEXT_SIZE_DEFAULT};",
28
+ f"window.MEGA_LINE_SPACING_DEFAULT = {MEGA_LINE_SPACING_DEFAULT};",
29
+ f"window.ANIM_DISPLAY_MODE_DEFAULT = {json.dumps(ANIM_DISPLAY_MODE_DEFAULT)};",
30
+ f"window.ANIM_GRANULARITY_DEFAULT = {json.dumps(ANIM_GRANULARITY_DEFAULT)};",
31
+ f"window.ANIM_OPACITY_PREV_DEFAULT = {ANIM_OPACITY_PREV_DEFAULT};",
32
+ f"window.ANIM_OPACITY_AFTER_DEFAULT = {ANIM_OPACITY_AFTER_DEFAULT};",
33
+ f"window.ANIM_WINDOW_PREV_DEFAULT = {ANIM_WINDOW_PREV_DEFAULT};",
34
+ f"window.ANIM_WINDOW_AFTER_DEFAULT = {ANIM_WINDOW_AFTER_DEFAULT};",
35
+ ]
36
+ config = "\n".join(config_lines)
37
+
38
+ core_js = (_STATIC_DIR / "animation-core.js").read_text(encoding="utf-8")
39
+ all_js = (_STATIC_DIR / "animate-all.js").read_text(encoding="utf-8")
40
+
41
+ return (
42
+ "<script>\n"
43
+ f"{config}\n"
44
+ "(function(){\n"
45
+ f"{core_js}\n"
46
+ f"{all_js}\n"
47
+ "})();\n"
48
+ "</script>"
49
+ )
src/ui/static/animate-all.js ADDED
@@ -0,0 +1,1153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =====================================================================
2
+ // Surah name ligature map (font triggers)
3
+ // =====================================================================
4
+ var surahLigatures = window.SURAH_LIGATURES;
5
+
6
+ // =====================================================================
7
+ // Animate All — continuous text stream with repetition handling
8
+ // =====================================================================
9
+ var animateAllState = {
10
+ active: false,
11
+ currentIdx: 0,
12
+ segments: [], // [{startTime, endTime, cacheWords, cacheChars, wordEls}]
13
+ rafId: null,
14
+ megaCard: null,
15
+ textFlow: null, // the unified .mega-text-flow container
16
+ btn: null,
17
+ unifiedCacheWords: [],
18
+ unifiedCacheChars: [],
19
+ unifiedAudio: null, // Single audio element for entire recording
20
+ accordionParent: null,
21
+ accordionNextSibling: null,
22
+ completedIdx: -1
23
+ };
24
+
25
+ function buildMegaCard() {
26
+ var container = document.querySelector('.segments-container');
27
+ if (!container) return null;
28
+
29
+ // Create unified audio element from full audio URL
30
+ var fullAudioUrl = container.dataset.fullAudio;
31
+ var unifiedAudio = null;
32
+ if (fullAudioUrl) {
33
+ unifiedAudio = document.createElement('audio');
34
+ unifiedAudio.src = fullAudioUrl;
35
+ unifiedAudio.preload = 'auto';
36
+ unifiedAudio.style.display = 'none';
37
+ }
38
+
39
+ var cards = Array.from(container.querySelectorAll('.segment-card'));
40
+ var mega = document.createElement('div');
41
+ mega.className = 'mega-card';
42
+ var textFlow = document.createElement('div');
43
+ textFlow.className = 'mega-text-flow';
44
+
45
+ var renderedPositions = {}; // data-pos string -> DOM element in textFlow
46
+ var renderedCharEls = {}; // data-pos string -> Array of .char elements
47
+ var renderedMarkers = {}; // "surah:marker" -> true, to avoid duplicate verse markers
48
+ var frag = document.createDocumentFragment();
49
+ var segments = [];
50
+ var lastSurah = null;
51
+ var pendingSpecial = null; // buffered special text to flush after surah separator
52
+
53
+ // Helper: flush pending special line into fragment
54
+ function flushPendingSpecial() {
55
+ if (!pendingSpecial) return;
56
+ var prevChild = frag.lastChild;
57
+ if (prevChild && prevChild.classList && prevChild.classList.contains('mega-special-line')) {
58
+ prevChild.textContent += ' ' + pendingSpecial;
59
+ } else {
60
+ var specialLine = document.createElement('div');
61
+ specialLine.className = 'mega-special-line';
62
+ specialLine.textContent = pendingSpecial;
63
+ frag.appendChild(specialLine);
64
+ }
65
+ pendingSpecial = null;
66
+ }
67
+
68
+ cards.forEach(function(card) {
69
+ var btn = card.querySelector('.animate-btn');
70
+ var ref = (card.dataset.matchedRef || '').trim();
71
+ var isSpecial = (ref === 'Basmala' || ref === "Isti'adha");
72
+
73
+ if (!btn || btn.disabled) {
74
+ // Special segment without timestamps — buffer static text
75
+ if (isSpecial || ref === '') {
76
+ var textEl = card.querySelector('.segment-text');
77
+ if (textEl) {
78
+ var txt = textEl.textContent.trim();
79
+ if (txt) {
80
+ if (pendingSpecial) {
81
+ pendingSpecial += ' ' + txt;
82
+ } else {
83
+ pendingSpecial = txt;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return;
89
+ }
90
+
91
+ // Animated special segment (has timestamps) — centered line with word animation
92
+ if (isSpecial) {
93
+ var textEl = card.querySelector('.segment-text');
94
+ if (!textEl) return;
95
+
96
+ // Extract segment boundaries from card data attributes
97
+ var segStartTime = parseFloat(card.dataset.startTime) || 0;
98
+ var segEndTime = parseFloat(card.dataset.endTime) || 0;
99
+
100
+ // Flush any buffered specials first
101
+ flushPendingSpecial();
102
+
103
+ var specialDiv;
104
+ var prevChild = frag.lastChild;
105
+ if (prevChild && prevChild.classList && prevChild.classList.contains('mega-special-line')) {
106
+ specialDiv = prevChild;
107
+ specialDiv.appendChild(document.createTextNode(' '));
108
+ } else {
109
+ specialDiv = document.createElement('div');
110
+ specialDiv.className = 'mega-special-line';
111
+ frag.appendChild(specialDiv);
112
+ }
113
+
114
+ var sourceWords = Array.from(textEl.querySelectorAll('.word'));
115
+ var segWordEls = [];
116
+ var segWordTimings = [];
117
+ var segCharTimings = [];
118
+ var unifiedCharEls = {};
119
+
120
+ sourceWords.forEach(function(node) {
121
+ var pos = node.dataset.pos;
122
+ var clone = node.cloneNode(true);
123
+ specialDiv.appendChild(document.createTextNode(' '));
124
+ specialDiv.appendChild(clone);
125
+ if (pos) renderedPositions[pos] = clone;
126
+ segWordEls.push(clone);
127
+
128
+ segWordTimings.push({
129
+ start: parseFloat(node.dataset.start) || 0,
130
+ end: parseFloat(node.dataset.end) || 0
131
+ });
132
+
133
+ var wordIdx = segWordEls.length - 1;
134
+ var chars = Array.from(clone.children);
135
+ unifiedCharEls[wordIdx] = chars;
136
+ if (pos) renderedCharEls[pos] = chars;
137
+
138
+ Array.from(node.children).forEach(function(srcChar) {
139
+ segCharTimings.push({
140
+ start: parseFloat(srcChar.dataset.start) || 0,
141
+ end: parseFloat(srcChar.dataset.end) || 0,
142
+ parentWordIdx: wordIdx
143
+ });
144
+ });
145
+ });
146
+
147
+ if (segWordEls.length === 0) return;
148
+
149
+ var cacheWords = segWordEls.map(function(el, j) {
150
+ return { el: el, start: segWordTimings[j].start, end: segWordTimings[j].end };
151
+ });
152
+ var cacheChars = [];
153
+ var charCountPerWord = {};
154
+ segCharTimings.forEach(function(ct) {
155
+ var wIdx = ct.parentWordIdx;
156
+ if (charCountPerWord[wIdx] === undefined) charCountPerWord[wIdx] = 0;
157
+ var charIdxInWord = charCountPerWord[wIdx]++;
158
+ var unifiedChars = unifiedCharEls[wIdx];
159
+ if (unifiedChars && charIdxInWord < unifiedChars.length) {
160
+ cacheChars.push({ el: unifiedChars[charIdxInWord], start: ct.start, end: ct.end });
161
+ }
162
+ });
163
+
164
+ segments.push({
165
+ startTime: segStartTime,
166
+ endTime: segEndTime,
167
+ cacheWords: cacheWords,
168
+ cacheChars: cacheChars,
169
+ wordEls: segWordEls
170
+ });
171
+ return;
172
+ }
173
+
174
+ var textEl = card.querySelector('.segment-text');
175
+ if (!textEl) return;
176
+
177
+ // Extract segment boundaries from card data attributes
178
+ var segStartTime = parseFloat(card.dataset.startTime) || 0;
179
+ var segEndTime = parseFloat(card.dataset.endTime) || 0;
180
+
181
+ // Detect fused special prefix: leading .word elements with :0:0: in data-pos
182
+ var allWords = Array.from(textEl.querySelectorAll('.word'));
183
+ var fusedWords = [];
184
+ var fusedHasTimestamps = false;
185
+ for (var fi = 0; fi < allWords.length; fi++) {
186
+ var fpos = allWords[fi].dataset.pos || '';
187
+ if (fpos && fpos.indexOf(':0:0:') !== -1) {
188
+ fusedWords.push(allWords[fi]);
189
+ if (allWords[fi].dataset.start) fusedHasTimestamps = true;
190
+ } else if (fpos) {
191
+ break; // stop at first verse word
192
+ } else {
193
+ // No data-pos at all (old-style) — static fallback
194
+ fusedWords = [];
195
+ break;
196
+ }
197
+ }
198
+
199
+ if (fusedWords.length > 0 && fusedHasTimestamps) {
200
+ // Animated fused prefix — clone into a mega-special-line, share audio
201
+ flushPendingSpecial();
202
+ var fusedDiv;
203
+ var prevChild = frag.lastChild;
204
+ if (prevChild && prevChild.classList && prevChild.classList.contains('mega-special-line')) {
205
+ fusedDiv = prevChild;
206
+ fusedDiv.appendChild(document.createTextNode(' '));
207
+ } else {
208
+ fusedDiv = document.createElement('div');
209
+ fusedDiv.className = 'mega-special-line';
210
+ frag.appendChild(fusedDiv);
211
+ }
212
+ var fusedWordEls = [];
213
+ var fusedWordTimings = [];
214
+ var fusedCharTimings = [];
215
+ var fusedCharEls = {};
216
+ fusedWords.forEach(function(node) {
217
+ var clone = node.cloneNode(true);
218
+ fusedDiv.appendChild(document.createTextNode(' '));
219
+ fusedDiv.appendChild(clone);
220
+ var pos = node.dataset.pos;
221
+ if (pos) renderedPositions[pos] = clone;
222
+ fusedWordEls.push(clone);
223
+ fusedWordTimings.push({
224
+ start: parseFloat(node.dataset.start) || 0,
225
+ end: parseFloat(node.dataset.end) || 0
226
+ });
227
+ var wordIdx = fusedWordEls.length - 1;
228
+ var chars = Array.from(clone.children);
229
+ fusedCharEls[wordIdx] = chars;
230
+ if (pos) renderedCharEls[pos] = chars;
231
+ Array.from(node.children).forEach(function(srcChar) {
232
+ fusedCharTimings.push({
233
+ start: parseFloat(srcChar.dataset.start) || 0,
234
+ end: parseFloat(srcChar.dataset.end) || 0,
235
+ parentWordIdx: wordIdx
236
+ });
237
+ });
238
+ });
239
+ // These fused words will be added to the same segment entry below
240
+ // (they share the audio with the verse words)
241
+ } else if (fusedWords.length > 0) {
242
+ // Fused prefix without timestamps — static text fallback
243
+ var fusedTxt = fusedWords.map(function(w) { return w.textContent; }).join(' ').trim();
244
+ if (fusedTxt) {
245
+ if (pendingSpecial) {
246
+ pendingSpecial += ' ' + fusedTxt;
247
+ } else {
248
+ pendingSpecial = fusedTxt;
249
+ }
250
+ }
251
+ }
252
+
253
+ var sourceWords = Array.from(textEl.querySelectorAll('.word'));
254
+ var segWordEls = []; // unified DOM elements this segment animates
255
+ var segWordTimings = []; // {start, end} from source card
256
+ var segCharTimings = []; // [{start, end, parentWordIdx}, ...]
257
+ var unifiedCharEls = {}; // wordIdx -> Array of .char elements in unified DOM
258
+
259
+ // Iterate source childNodes to copy words + verse markers, deduplicating by data-pos
260
+ var childNodes = Array.from(textEl.childNodes);
261
+ var lastWasNew = false; // track if the last word was newly appended
262
+ childNodes.forEach(function(node) {
263
+ if (node.nodeType === Node.ELEMENT_NODE && node.classList && node.classList.contains('word')) {
264
+ var pos = node.dataset.pos;
265
+ if (!pos) return; // skip words without data-pos
266
+ if (pos.indexOf(':0:0:') !== -1) return; // skip fused prefix words (handled above)
267
+ if (renderedPositions[pos]) {
268
+ // Word already in unified text — reuse existing element
269
+ segWordEls.push(renderedPositions[pos]);
270
+ lastWasNew = false;
271
+ } else {
272
+ // New word — clone and append
273
+ var clone = node.cloneNode(true);
274
+ // Detect surah change for separator
275
+ var posParts = pos.split(':');
276
+ if (posParts.length >= 1 && posParts[0]) {
277
+ var wordSurah = posParts[0];
278
+ if (wordSurah !== lastSurah) {
279
+ var sepDiv = document.createElement('div');
280
+ sepDiv.className = 'mega-surah-separator';
281
+ if (lastSurah === null) sepDiv.style.borderTop = 'none';
282
+ var ligKey = 'surah-' + wordSurah;
283
+ sepDiv.textContent = surahLigatures[ligKey] || wordSurah;
284
+ // Insert before any trailing special line so separator comes before basmala
285
+ var trailingSpecial = frag.lastChild;
286
+ if (trailingSpecial && trailingSpecial.classList && trailingSpecial.classList.contains('mega-special-line')) {
287
+ frag.insertBefore(sepDiv, trailingSpecial);
288
+ } else {
289
+ frag.appendChild(sepDiv);
290
+ }
291
+ }
292
+ lastSurah = wordSurah;
293
+ }
294
+ // Flush buffered special text after separator, before first word
295
+ flushPendingSpecial();
296
+ frag.appendChild(document.createTextNode(' '));
297
+ frag.appendChild(clone);
298
+ if (pos) {
299
+ renderedPositions[pos] = clone;
300
+ }
301
+ segWordEls.push(clone);
302
+ lastWasNew = true;
303
+ }
304
+ // Read timing from source word
305
+ segWordTimings.push({
306
+ start: parseFloat(node.dataset.start) || 0,
307
+ end: parseFloat(node.dataset.end) || 0
308
+ });
309
+ // Read char timings from source and cache unified char elements
310
+ var wordIdx = segWordEls.length - 1;
311
+ var pos2 = node.dataset.pos;
312
+ if (pos2 && renderedCharEls[pos2]) {
313
+ unifiedCharEls[wordIdx] = renderedCharEls[pos2];
314
+ } else {
315
+ var chars = Array.from(segWordEls[wordIdx].children);
316
+ unifiedCharEls[wordIdx] = chars;
317
+ if (pos2) renderedCharEls[pos2] = chars;
318
+ }
319
+ var srcChars = Array.from(node.children);
320
+ srcChars.forEach(function(srcChar) {
321
+ segCharTimings.push({
322
+ start: parseFloat(srcChar.dataset.start) || 0,
323
+ end: parseFloat(srcChar.dataset.end) || 0,
324
+ parentWordIdx: wordIdx
325
+ });
326
+ });
327
+ } else if (node.nodeType === Node.ELEMENT_NODE && lastWasNew) {
328
+ // Verse marker or other non-word element — append only if preceding word was new
329
+ var markerText = node.textContent || '';
330
+ var markerKey = (lastSurah || '') + ':' + markerText.trim();
331
+ if (!markerText.trim() || !renderedMarkers[markerKey]) {
332
+ frag.appendChild(document.createTextNode(' '));
333
+ var markerSpan = document.createElement('span');
334
+ markerSpan.className = 'verse-marker';
335
+ markerSpan.title = 'Jump to this verse';
336
+ markerSpan.appendChild(node.cloneNode(true));
337
+ // Extract verse from preceding word's data-pos (surah:ayah:word)
338
+ var lastWordPos = segWordEls.length > 0 ? (segWordEls[segWordEls.length - 1].dataset.pos || '') : '';
339
+ var posPartsM = lastWordPos.split(':');
340
+ if (posPartsM.length >= 2) markerSpan.dataset.verse = posPartsM[0] + ':' + posPartsM[1];
341
+ frag.appendChild(markerSpan);
342
+ if (markerText.trim()) renderedMarkers[markerKey] = true;
343
+ }
344
+ } else if (node.nodeType === Node.TEXT_NODE && lastWasNew) {
345
+ // Verse markers are plain text nodes (e.g. ۝٢٥٥), not elements
346
+ var txt = node.textContent || '';
347
+ if (txt.trim()) {
348
+ var markerKey = (lastSurah || '') + ':' + txt.trim();
349
+ if (!renderedMarkers[markerKey]) {
350
+ frag.appendChild(document.createTextNode(' '));
351
+ var markerSpan2 = document.createElement('span');
352
+ markerSpan2.className = 'verse-marker';
353
+ markerSpan2.title = 'Jump to this verse';
354
+ markerSpan2.textContent = txt.trim();
355
+ var lastWordPos2 = segWordEls.length > 0 ? (segWordEls[segWordEls.length - 1].dataset.pos || '') : '';
356
+ var posPartsM2 = lastWordPos2.split(':');
357
+ if (posPartsM2.length >= 2) markerSpan2.dataset.verse = posPartsM2[0] + ':' + posPartsM2[1];
358
+ frag.appendChild(markerSpan2);
359
+ renderedMarkers[markerKey] = true;
360
+ }
361
+ }
362
+ }
363
+ });
364
+
365
+ if (segWordEls.length === 0) return;
366
+
367
+ // Build caches: pair source timings with unified DOM elements
368
+ var cacheWords = segWordEls.map(function(el, j) {
369
+ return {
370
+ el: el,
371
+ start: segWordTimings[j].start,
372
+ end: segWordTimings[j].end
373
+ };
374
+ });
375
+
376
+ // Build char cache using pre-collected unified char elements
377
+ var cacheChars = [];
378
+ var charCountPerWord = {};
379
+ segCharTimings.forEach(function(ct) {
380
+ var wIdx = ct.parentWordIdx;
381
+ if (charCountPerWord[wIdx] === undefined) charCountPerWord[wIdx] = 0;
382
+ var charIdxInWord = charCountPerWord[wIdx]++;
383
+ var unifiedChars = unifiedCharEls[wIdx];
384
+ if (unifiedChars && charIdxInWord < unifiedChars.length) {
385
+ cacheChars.push({
386
+ el: unifiedChars[charIdxInWord],
387
+ start: ct.start,
388
+ end: ct.end
389
+ });
390
+ }
391
+ });
392
+
393
+ // Prepend animated fused prefix words/chars (same audio segment)
394
+ if (typeof fusedWordEls !== 'undefined' && fusedWordEls.length > 0 && fusedHasTimestamps) {
395
+ var fusedCacheWords = fusedWordEls.map(function(el, j) {
396
+ return { el: el, start: fusedWordTimings[j].start, end: fusedWordTimings[j].end };
397
+ });
398
+ var fusedCacheChars = [];
399
+ var fusedCharCount = {};
400
+ fusedCharTimings.forEach(function(ct) {
401
+ var wIdx = ct.parentWordIdx;
402
+ if (fusedCharCount[wIdx] === undefined) fusedCharCount[wIdx] = 0;
403
+ var charIdx = fusedCharCount[wIdx]++;
404
+ var chars = fusedCharEls[wIdx];
405
+ if (chars && charIdx < chars.length) {
406
+ fusedCacheChars.push({ el: chars[charIdx], start: ct.start, end: ct.end });
407
+ }
408
+ });
409
+ cacheWords = fusedCacheWords.concat(cacheWords);
410
+ cacheChars = fusedCacheChars.concat(cacheChars);
411
+ segWordEls = fusedWordEls.concat(segWordEls);
412
+ // Reset fused state for next card
413
+ fusedWordEls = []; fusedHasTimestamps = false;
414
+ }
415
+
416
+ // Build group index for segment caches (for character grouping in animations)
417
+ buildGroupIndex(cacheWords);
418
+ buildGroupIndex(cacheChars);
419
+
420
+ segments.push({
421
+ startTime: segStartTime,
422
+ endTime: segEndTime,
423
+ cacheWords: cacheWords,
424
+ cacheChars: cacheChars,
425
+ wordEls: segWordEls
426
+ });
427
+ });
428
+
429
+ // Flush any remaining buffered special text
430
+ flushPendingSpecial();
431
+
432
+ textFlow.appendChild(frag);
433
+ // Build unified caches so Window mode spans all segments.
434
+ // Deduplicate: each DOM element appears once (shared elements from
435
+ // repeated segments reuse the first segment's unified index).
436
+ var unifiedWords = [];
437
+ var unifiedChars = [];
438
+ var wordElToIdx = new Map();
439
+ var charElToIdx = new Map();
440
+ var elToFirstSeg = new Map();
441
+ segments.forEach(function(seg, sIdx) {
442
+ seg.sharedEls = new Set();
443
+ seg.unifiedWordMap = [];
444
+ seg.unifiedCharMap = [];
445
+ for (var i = 0; i < seg.cacheWords.length; i++) {
446
+ var el = seg.cacheWords[i].el;
447
+ if (wordElToIdx.has(el)) {
448
+ seg.unifiedWordMap.push(wordElToIdx.get(el));
449
+ seg.sharedEls.add(el);
450
+ segments[elToFirstSeg.get(el)].sharedEls.add(el);
451
+ } else {
452
+ var idx = unifiedWords.length;
453
+ wordElToIdx.set(el, idx);
454
+ elToFirstSeg.set(el, sIdx);
455
+ seg.unifiedWordMap.push(idx);
456
+ unifiedWords.push(seg.cacheWords[i]);
457
+ }
458
+ }
459
+ for (var i = 0; i < seg.cacheChars.length; i++) {
460
+ var el = seg.cacheChars[i].el;
461
+ if (charElToIdx.has(el)) {
462
+ seg.unifiedCharMap.push(charElToIdx.get(el));
463
+ } else {
464
+ var idx = unifiedChars.length;
465
+ charElToIdx.set(el, idx);
466
+ seg.unifiedCharMap.push(idx);
467
+ unifiedChars.push(seg.cacheChars[i]);
468
+ }
469
+ }
470
+ });
471
+ // Tag each word/char element with its segment index for click-to-seek
472
+ segments.forEach(function(seg, sIdx) {
473
+ seg.cacheWords.forEach(function(c) { c.el.dataset.segIdx = sIdx; });
474
+ seg.cacheChars.forEach(function(c) { c.el.dataset.segIdx = sIdx; });
475
+ });
476
+ // Build group index for unified caches (for character grouping across all segments)
477
+ buildGroupIndex(unifiedWords);
478
+ buildGroupIndex(unifiedChars);
479
+
480
+ animateAllState.segments = segments;
481
+ animateAllState.unifiedCacheWords = unifiedWords;
482
+ animateAllState.unifiedCacheChars = unifiedChars;
483
+ animateAllState.textFlow = textFlow;
484
+ animDebug('MEGA', 'buildMegaCard: ' + segments.length + ' segments, unifiedWords=' + unifiedWords.length + ' unifiedChars=' + unifiedChars.length);
485
+ dumpCacheTimestamps(unifiedWords, 'Unified Words');
486
+ dumpCacheTimestamps(unifiedChars, 'Unified Chars');
487
+ mega.appendChild(textFlow);
488
+
489
+ // Build top bar with Exit button (placed outside mega card)
490
+ var topBar = document.createElement('div');
491
+ topBar.className = 'mega-top-bar';
492
+ var exitBtn = document.createElement('button');
493
+ exitBtn.className = 'mega-exit-btn';
494
+ exitBtn.textContent = 'Exit';
495
+ exitBtn.title = 'Exit and return to individual segments';
496
+ topBar.appendChild(exitBtn);
497
+ // Speed dropdown
498
+ var speedSelect = document.createElement('select');
499
+ speedSelect.className = 'mega-speed-select';
500
+ speedSelect.title = 'Playback speed';
501
+ [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2].forEach(function(r) {
502
+ var opt = document.createElement('option');
503
+ opt.value = r;
504
+ opt.textContent = r + 'x';
505
+ if (r === (window.ANIM_PLAYBACK_RATE || 1)) opt.selected = true;
506
+ speedSelect.appendChild(opt);
507
+ });
508
+ speedSelect.addEventListener('change', function() {
509
+ var rate = parseFloat(speedSelect.value);
510
+ window.ANIM_PLAYBACK_RATE = rate;
511
+ if (animateAllState.unifiedAudio) {
512
+ animateAllState.unifiedAudio.playbackRate = rate;
513
+ }
514
+ saveAnimSettings();
515
+ });
516
+ topBar.appendChild(speedSelect);
517
+ return {mega: mega, topBar: topBar, unifiedAudio: unifiedAudio};
518
+ }
519
+
520
+ // Reset highlight state for backward transitions (cache-based, no DOM queries)
521
+ function resetHighlightsFrom(segIdx) {
522
+ for (var s = segIdx; s < animateAllState.segments.length; s++) {
523
+ var seg = animateAllState.segments[s];
524
+ seg.cacheWords.forEach(function(c) {
525
+ c.el.classList.remove('active', 'reached');
526
+ c.el.style.removeProperty('opacity');
527
+ });
528
+ seg.cacheChars.forEach(function(c) {
529
+ c.el.classList.remove('active', 'reached');
530
+ c.el.style.removeProperty('opacity');
531
+ });
532
+ }
533
+ // Re-apply 'reached' to shared elements belonging to completed earlier segments
534
+ for (var s = 0; s < segIdx && s <= animateAllState.completedIdx; s++) {
535
+ var prev = animateAllState.segments[s];
536
+ if (!prev.sharedEls || prev.sharedEls.size === 0) continue;
537
+ prev.cacheWords.forEach(function(c) {
538
+ if (prev.sharedEls.has(c.el)) c.el.classList.add('reached');
539
+ });
540
+ prev.cacheChars.forEach(function(c) {
541
+ if (prev.sharedEls.has(c.el)) c.el.classList.add('reached');
542
+ });
543
+ }
544
+ }
545
+
546
+ function startSegmentTick(seg, audio, segStartTime, segEndTime) {
547
+ var lastWordIdx = -1;
548
+ var lastGranularity = window.ANIM_GRANULARITY;
549
+ var lastOpacityPrev = window.ANIM_OPACITY_PREV;
550
+ var lastSeenVersion = window._windowSettingsVersion;
551
+ var segIdx = animateAllState.currentIdx;
552
+ animDebug('MEGA', 'startSegmentTick seg=' + segIdx + ' cacheWords=' + seg.cacheWords.length + ' cacheChars=' + seg.cacheChars.length + ' bounds=[' + segStartTime.toFixed(3) + ',' + segEndTime.toFixed(3) + ']');
553
+ function tick() {
554
+ if (audio.paused) return;
555
+ var currentTime = audio.currentTime;
556
+
557
+ // Check segment end boundary (unified audio spans all segments)
558
+ if (currentTime >= segEndTime) {
559
+ onAnimateAllSegmentEnd();
560
+ return;
561
+ }
562
+
563
+ var cache = window.ANIM_GRANULARITY === 'Characters' ? seg.cacheChars : seg.cacheWords;
564
+ if (window.ANIM_GRANULARITY !== lastGranularity) {
565
+ animDebug('MEGA', 'Granularity changed: ' + lastGranularity + ' -> ' + window.ANIM_GRANULARITY);
566
+ // Granularity changed mid-animation: reset this segment's elements
567
+ seg.wordEls.forEach(function(w) {
568
+ w.classList.remove('active');
569
+ w.style.removeProperty('opacity');
570
+ w.querySelectorAll('.char').forEach(function(c) {
571
+ c.classList.remove('active');
572
+ c.style.removeProperty('opacity');
573
+ });
574
+ });
575
+ // Clear inline opacity across all segments (previous segments may have inline opacity)
576
+ animateAllState.unifiedCacheWords.forEach(function(c) { c.el.style.removeProperty('opacity'); });
577
+ animateAllState.unifiedCacheChars.forEach(function(c) { c.el.style.removeProperty('opacity'); });
578
+ lastWordIdx = -1;
579
+ lastGranularity = window.ANIM_GRANULARITY;
580
+ }
581
+
582
+ // Mode changed mid-animation — refresh all reached words with new opacity
583
+ if (window.ANIM_OPACITY_PREV !== lastOpacityPrev) {
584
+ animDebug('MEGA', 'Mode changed: prevOp ' + lastOpacityPrev + ' -> ' + window.ANIM_OPACITY_PREV);
585
+ animateAllState.unifiedCacheWords.forEach(function(c) {
586
+ if (c.el.classList.contains('reached')) {
587
+ if (window.ANIM_OPACITY_PREV >= 1) {
588
+ c.el.style.removeProperty('opacity');
589
+ } else {
590
+ c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
591
+ }
592
+ }
593
+ });
594
+ animateAllState.unifiedCacheChars.forEach(function(c) {
595
+ if (c.el.classList.contains('reached')) {
596
+ if (window.ANIM_OPACITY_PREV >= 1) {
597
+ c.el.style.removeProperty('opacity');
598
+ } else {
599
+ c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
600
+ }
601
+ }
602
+ });
603
+ lastOpacityPrev = window.ANIM_OPACITY_PREV;
604
+ }
605
+
606
+ // Slider settings changed mid-animation — reapply window opacity
607
+ if (window._windowSettingsVersion !== lastSeenVersion) {
608
+ if (lastWordIdx >= 0) {
609
+ var wc = window.ANIM_GRANULARITY === 'Characters'
610
+ ? animateAllState.unifiedCacheChars : animateAllState.unifiedCacheWords;
611
+ var map = window.ANIM_GRANULARITY === 'Characters'
612
+ ? seg.unifiedCharMap : seg.unifiedWordMap;
613
+ if (map[lastWordIdx] !== undefined) {
614
+ applyWindowOpacity(wc, map[lastWordIdx], map[lastWordIdx]);
615
+ }
616
+ }
617
+ lastSeenVersion = window._windowSettingsVersion;
618
+ }
619
+
620
+ var newWordIdx = -1;
621
+ var searchPath = '';
622
+ // Fast path: check current word, then next (covers ~99% of frames)
623
+ if (lastWordIdx >= 0 && lastWordIdx < cache.length &&
624
+ currentTime >= cache[lastWordIdx].start && currentTime < cache[lastWordIdx].end) {
625
+ newWordIdx = lastWordIdx;
626
+ searchPath = 'same';
627
+ } else if (lastWordIdx + 1 < cache.length &&
628
+ currentTime >= cache[lastWordIdx + 1].start && currentTime < cache[lastWordIdx + 1].end) {
629
+ newWordIdx = lastWordIdx + 1;
630
+ searchPath = 'next';
631
+ } else {
632
+ // Fallback: full scan (seeking, granularity switch, etc.)
633
+ searchPath = 'scan';
634
+ for (var i = 0; i < cache.length; i++) {
635
+ if (currentTime >= cache[i].start && currentTime < cache[i].end) {
636
+ newWordIdx = i;
637
+ break;
638
+ }
639
+ }
640
+ // Clamp to last word when past its end but audio hasn't ended yet
641
+ if (newWordIdx === -1 && cache.length > 0 && currentTime >= cache[cache.length - 1].start) {
642
+ newWordIdx = cache.length - 1;
643
+ searchPath = 'clamp';
644
+ }
645
+ }
646
+ if (newWordIdx !== lastWordIdx) {
647
+ var newText = newWordIdx >= 0 ? cache[newWordIdx].el.textContent.substring(0, 15) : '-';
648
+ animDebug('MEGA', 'seg=' + segIdx + ' idx: ' + lastWordIdx + ' -> ' + newWordIdx + ' (' + searchPath + ') t=' + currentTime.toFixed(3) + ' "' + newText + '"');
649
+ if (newWordIdx === -1 && cache.length > 0) {
650
+ var first = cache[0];
651
+ var last = cache[cache.length - 1];
652
+ animDebug('MEGA', ' NO MATCH: t=' + currentTime.toFixed(3) + ' cache[0]=[' + first.start.toFixed(3) + ',' + first.end.toFixed(3) + '] cache[' + (cache.length-1) + ']=[' + last.start.toFixed(3) + ',' + last.end.toFixed(3) + ']');
653
+ }
654
+ if (lastWordIdx >= 0 && lastWordIdx < cache.length) {
655
+ applyClassToGroup(cache, lastWordIdx, 'active', false);
656
+ applyClassToGroup(cache, lastWordIdx, 'reached', true);
657
+ }
658
+ if (newWordIdx >= 0) {
659
+ applyClassToGroup(cache, newWordIdx, 'active', true);
660
+ if (lastWordIdx === -1) {
661
+ // First highlight — catch up any skipped words (with group support)
662
+ for (var j = 0; j < newWordIdx; j++) {
663
+ applyClassToGroup(cache, j, 'reached', true);
664
+ }
665
+ }
666
+ // auto-scroll disabled — causes jank with frequent word changes
667
+ }
668
+ if (newWordIdx >= 0) {
669
+ var wc = window.ANIM_GRANULARITY === 'Characters'
670
+ ? animateAllState.unifiedCacheChars : animateAllState.unifiedCacheWords;
671
+ var map = window.ANIM_GRANULARITY === 'Characters' ? seg.unifiedCharMap : seg.unifiedWordMap;
672
+ applyWindowOpacity(wc, map[newWordIdx],
673
+ lastWordIdx >= 0 ? map[lastWordIdx] : -1);
674
+ }
675
+ lastWordIdx = newWordIdx;
676
+ }
677
+ }
678
+ if (animateAllState.rafId) cancelAnimationFrame(animateAllState.rafId);
679
+ function rafLoop() {
680
+ tick();
681
+ // Keep looping while animation is active AND still on the same segment.
682
+ // When tick() triggers a segment transition, a new RAF loop is started for
683
+ // the new segment - this old loop must stop to avoid duplicate transitions.
684
+ if (animateAllState.active && !audio.paused && animateAllState.currentIdx === segIdx) {
685
+ animateAllState.rafId = requestAnimationFrame(rafLoop);
686
+ }
687
+ }
688
+ animateAllState.rafId = requestAnimationFrame(rafLoop);
689
+ }
690
+
691
+ function animateSegment(idx, seekTime) {
692
+ var seg = animateAllState.segments[idx];
693
+ if (!seg) return;
694
+ animateAllState.currentIdx = idx;
695
+ // Re-tag shared elements so click-to-seek targets this segment during playback
696
+ seg.cacheWords.forEach(function(c) { c.el.dataset.segIdx = idx; });
697
+ seg.cacheChars.forEach(function(c) { c.el.dataset.segIdx = idx; });
698
+ var audio = animateAllState.unifiedAudio;
699
+ // Apply window engine class to mega card
700
+ var mega = animateAllState.megaCard;
701
+ mega.classList.add('anim-window');
702
+ if (window.ANIM_GRANULARITY === 'Characters') {
703
+ mega.classList.add('anim-chars');
704
+ }
705
+ // Detect backward transition: jumping to a segment at or before the last completed one
706
+ if (idx <= animateAllState.completedIdx) {
707
+ resetHighlightsFrom(idx);
708
+ // Reset completedIdx so forward progress from here is tracked correctly
709
+ animateAllState.completedIdx = idx - 1;
710
+ }
711
+ // Determine seek position: use provided seekTime or default to segment start
712
+ var targetTime = (seekTime !== undefined) ? seekTime : seg.startTime;
713
+ // Start playback and polling with segment boundaries
714
+ startSegmentTick(seg, audio, seg.startTime, seg.endTime);
715
+ audio.currentTime = targetTime;
716
+ audio.playbackRate = window.ANIM_PLAYBACK_RATE || 1;
717
+ audio.play();
718
+ // No per-segment preloading needed — unified audio already loaded
719
+ }
720
+
721
+ function seekToWord(wordEl) {
722
+ if (!animateAllState.active) return;
723
+ var cache = window.ANIM_GRANULARITY === 'Characters'
724
+ ? animateAllState.unifiedCacheChars : animateAllState.unifiedCacheWords;
725
+ // Find unified index and start time for the clicked word
726
+ var targetIdx = -1;
727
+ var wordStartTime = 0;
728
+ // For char granularity, find the first char of the clicked word
729
+ if (window.ANIM_GRANULARITY === 'Characters') {
730
+ for (var i = 0; i < cache.length; i++) {
731
+ if (cache[i].el.parentElement === wordEl || cache[i].el === wordEl) {
732
+ targetIdx = i;
733
+ wordStartTime = cache[i].start;
734
+ break;
735
+ }
736
+ }
737
+ } else {
738
+ for (var i = 0; i < cache.length; i++) {
739
+ if (cache[i].el === wordEl) {
740
+ targetIdx = i;
741
+ wordStartTime = cache[i].start;
742
+ break;
743
+ }
744
+ }
745
+ }
746
+ if (targetIdx < 0) return;
747
+ // Derive segment from word timing (more reliable than data-segIdx for shared elements)
748
+ var segIdx = 0;
749
+ for (var s = 0; s < animateAllState.segments.length; s++) {
750
+ var seg = animateAllState.segments[s];
751
+ if (wordStartTime >= seg.startTime && wordStartTime < seg.endTime) {
752
+ segIdx = s;
753
+ break;
754
+ }
755
+ }
756
+ // Reset window cache to force full reapplication (not fast path)
757
+ window._windowActiveIdx = -1;
758
+ window._windowLastPcAll = false;
759
+ window._windowLastAcAll = false;
760
+ // Update highlight state: words before target = reached, target onward = clean
761
+ for (var i = 0; i < cache.length; i++) {
762
+ cache[i].el.classList.remove('active');
763
+ cache[i].el.style.removeProperty('opacity');
764
+ if (i < targetIdx) {
765
+ cache[i].el.classList.add('reached');
766
+ } else {
767
+ cache[i].el.classList.remove('reached');
768
+ }
769
+ }
770
+ // Also sync the other cache (chars if in word mode, words if in char mode)
771
+ var otherCache = window.ANIM_GRANULARITY === 'Characters'
772
+ ? animateAllState.unifiedCacheWords : animateAllState.unifiedCacheChars;
773
+ for (var i = 0; i < otherCache.length; i++) {
774
+ otherCache[i].el.classList.remove('active');
775
+ otherCache[i].el.style.removeProperty('opacity');
776
+ }
777
+ // Re-apply window opacity immediately for the new position
778
+ applyWindowOpacity(cache, targetIdx, -1);
779
+ // Switch segment or seek within current (unified audio)
780
+ var audio = animateAllState.unifiedAudio;
781
+ if (animateAllState.rafId) {
782
+ cancelAnimationFrame(animateAllState.rafId);
783
+ animateAllState.rafId = null;
784
+ }
785
+ if (segIdx !== animateAllState.currentIdx) {
786
+ // Different segment — restart tick loop with new segment boundaries
787
+ animateSegment(segIdx, wordStartTime);
788
+ } else {
789
+ // Same segment — restart tick loop at new position for clean state
790
+ audio.currentTime = wordStartTime;
791
+ var seg = animateAllState.segments[segIdx];
792
+ var segStartTime = segIdx > 0 ? animateAllState.segments[segIdx - 1].endTime : 0;
793
+ startSegmentTick(seg, audio, segStartTime, seg.endTime);
794
+ }
795
+ }
796
+
797
+ function seekToVerseMarker(markerEl) {
798
+ if (!animateAllState.active) return;
799
+ var verse = markerEl.dataset.verse; // e.g. "2:255"
800
+ if (!verse) return;
801
+ var prefix = verse + ':';
802
+ // Find first word in unified cache whose data-pos starts with this verse
803
+ var cache = animateAllState.unifiedCacheWords;
804
+ for (var i = 0; i < cache.length; i++) {
805
+ var pos = cache[i].el.dataset.pos || '';
806
+ if (pos === verse || pos.indexOf(prefix) === 0) {
807
+ seekToWord(cache[i].el);
808
+ return;
809
+ }
810
+ }
811
+ }
812
+
813
+ function onAnimateAllSegmentEnd() {
814
+ if (!animateAllState.active) return;
815
+ var seg = animateAllState.segments[animateAllState.currentIdx];
816
+ if (seg) {
817
+ // Mark all words/chars in finished segment as reached.
818
+ // Use mode's prev_opacity for completed elements.
819
+ seg.cacheWords.forEach(function(c) {
820
+ if (c.el.classList.contains('active')) {
821
+ c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
822
+ }
823
+ c.el.classList.remove('active');
824
+ c.el.classList.add('reached');
825
+ });
826
+ seg.cacheChars.forEach(function(c) {
827
+ if (c.el.classList.contains('active')) {
828
+ c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
829
+ }
830
+ c.el.classList.remove('active');
831
+ c.el.classList.add('reached');
832
+ });
833
+ animateAllState.completedIdx = animateAllState.currentIdx;
834
+ }
835
+ var nextIdx = animateAllState.currentIdx + 1;
836
+ if (nextIdx < animateAllState.segments.length) {
837
+ // Unified audio continues playing — just switch segment tracking
838
+ var nextSeg = animateAllState.segments[nextIdx];
839
+ animateAllState.currentIdx = nextIdx;
840
+ // Re-tag shared elements for the new segment
841
+ nextSeg.cacheWords.forEach(function(c) { c.el.dataset.segIdx = nextIdx; });
842
+ nextSeg.cacheChars.forEach(function(c) { c.el.dataset.segIdx = nextIdx; });
843
+ // Restart tick loop with new segment boundaries
844
+ startSegmentTick(nextSeg, animateAllState.unifiedAudio, nextSeg.startTime, nextSeg.endTime);
845
+ } else {
846
+ // All segments done — tear down after brief delay
847
+ setTimeout(function() { stopAnimateAll(); }, 500);
848
+ }
849
+ }
850
+
851
+ // Pause: keep mega card and position, just stop playback
852
+ function pauseAnimateAll() {
853
+ if (animateAllState.rafId) {
854
+ cancelAnimationFrame(animateAllState.rafId);
855
+ animateAllState.rafId = null;
856
+ }
857
+ if (animateAllState.unifiedAudio) animateAllState.unifiedAudio.pause();
858
+ animateAllState.active = false;
859
+
860
+ // Fade last active word to mode's prev_opacity
861
+ var seg = animateAllState.segments[animateAllState.currentIdx];
862
+ if (seg) {
863
+ var cache = window.ANIM_GRANULARITY === 'Characters' ? seg.cacheChars : seg.cacheWords;
864
+ cache.forEach(function(c) {
865
+ if (c.el.classList.contains('active')) {
866
+ c.el.style.opacity = String(window.ANIM_OPACITY_PREV);
867
+ }
868
+ });
869
+ }
870
+
871
+ if (animateAllState.btn) {
872
+ animateAllState.btn.classList.remove('active');
873
+ animateAllState.btn.textContent = 'Resume';
874
+ }
875
+ }
876
+
877
+ // Full teardown: remove mega card, restore segment cards
878
+ function stopAnimateAll() {
879
+ if (animateAllState.rafId) {
880
+ cancelAnimationFrame(animateAllState.rafId);
881
+ animateAllState.rafId = null;
882
+ }
883
+ if (animateAllState.unifiedAudio) animateAllState.unifiedAudio.pause();
884
+ // Move Stop/Resume button back to its original parent before removing mega card
885
+ if (animateAllState.btn && animateAllState.btnParent) {
886
+ animateAllState.btnParent.appendChild(animateAllState.btn);
887
+ }
888
+ // Remove tip callout, top bar, and mega card
889
+ var tipEl = document.querySelector('.mega-tip');
890
+ if (tipEl) tipEl.parentNode.removeChild(tipEl);
891
+ if (animateAllState.topBar && animateAllState.topBar.parentNode) {
892
+ animateAllState.topBar.parentNode.removeChild(animateAllState.topBar);
893
+ }
894
+ if (animateAllState.megaCard && animateAllState.megaCard.parentNode) {
895
+ animateAllState.megaCard.parentNode.removeChild(animateAllState.megaCard);
896
+ }
897
+ animateAllState.megaCard = null;
898
+ animateAllState.topBar = null;
899
+ animateAllState.textFlow = null;
900
+ // Unhide segment cards
901
+ document.querySelectorAll('.segment-card.hidden-for-mega').forEach(function(c) {
902
+ c.classList.remove('hidden-for-mega');
903
+ });
904
+ // Restore left column and right column sizing
905
+ var leftCol = document.getElementById('left-col');
906
+ if (leftCol) leftCol.style.display = '';
907
+ var mainRow = document.getElementById('main-row');
908
+ if (mainRow) {
909
+ var rightCol = mainRow.querySelector(':scope > div:last-child');
910
+ if (rightCol) rightCol.style.flexGrow = animateAllState.savedFlexGrow || '';
911
+ }
912
+ // Restore description and API accordion
913
+ if (mainRow) {
914
+ var sibling = mainRow.parentNode.firstElementChild;
915
+ while (sibling && sibling !== mainRow) {
916
+ sibling.style.display = '';
917
+ sibling = sibling.nextElementSibling;
918
+ }
919
+ }
920
+ // Restore header/summary text and action buttons
921
+ document.querySelectorAll('.segments-header, .segments-review-summary').forEach(function(el) {
922
+ el.style.display = '';
923
+ });
924
+ var actionRow = document.getElementById('action-btns-row');
925
+ if (actionRow) actionRow.style.display = '';
926
+ var tsRow = document.getElementById('ts-row');
927
+ if (tsRow) tsRow.style.display = '';
928
+ // Move animation settings accordion back to left column
929
+ var animAccordion = document.getElementById('anim-settings-accordion');
930
+ if (animAccordion && animateAllState.accordionParent) {
931
+ if (animateAllState.accordionNextSibling) {
932
+ animateAllState.accordionParent.insertBefore(animAccordion, animateAllState.accordionNextSibling);
933
+ } else {
934
+ animateAllState.accordionParent.appendChild(animAccordion);
935
+ }
936
+ }
937
+ // Hide mega styling sliders (only shown in megacard view)
938
+ var megaStylingRow = document.getElementById('mega-styling-row');
939
+ if (megaStylingRow) megaStylingRow.style.display = 'none';
940
+ // Reset button
941
+ if (animateAllState.btn) {
942
+ animateAllState.btn.classList.remove('active');
943
+ animateAllState.btn.textContent = 'Animate All';
944
+ }
945
+ animateAllState.active = false;
946
+ animateAllState.completedIdx = -1;
947
+ animateAllState.segments = [];
948
+ animateAllState.unifiedCacheWords = [];
949
+ animateAllState.unifiedCacheChars = [];
950
+ animateAllState.unifiedAudio = null;
951
+ animateAllState.megaCard = null;
952
+ animateAllState.textFlow = null;
953
+ window.ANIM_PLAYBACK_RATE = 1;
954
+ }
955
+
956
+ // Resume from paused position
957
+ function resumeAnimateAll() {
958
+ animateAllState.active = true;
959
+ if (animateAllState.btn) {
960
+ animateAllState.btn.classList.add('active');
961
+ animateAllState.btn.textContent = 'Stop';
962
+ }
963
+ var seg = animateAllState.segments[animateAllState.currentIdx];
964
+ if (!seg) return;
965
+ var audio = animateAllState.unifiedAudio;
966
+ // Re-apply animation classes
967
+ var mega = animateAllState.megaCard;
968
+ mega.classList.add('anim-window');
969
+ if (window.ANIM_GRANULARITY === 'Characters') {
970
+ mega.classList.add('anim-chars');
971
+ }
972
+ // Restart polling from current audio position
973
+ startSegmentTick(seg, audio, seg.startTime, seg.endTime);
974
+ audio.playbackRate = window.ANIM_PLAYBACK_RATE || 1;
975
+ audio.play();
976
+ }
977
+
978
+ function toggleAnimateAll(btn) {
979
+ // Currently playing → pause (keep mega card)
980
+ if (animateAllState.active) {
981
+ pauseAnimateAll();
982
+ return;
983
+ }
984
+ // Paused with mega card still in live DOM → resume
985
+ if (animateAllState.megaCard && document.contains(animateAllState.megaCard)) {
986
+ resumeAnimateAll();
987
+ return;
988
+ }
989
+ // Stale mega card (orphaned by Gradio HTML update) → reset state
990
+ if (animateAllState.megaCard) {
991
+ animateAllState.megaCard = null;
992
+ animateAllState.textFlow = null;
993
+ animateAllState.segments = [];
994
+ animateAllState.unifiedCacheWords = [];
995
+ animateAllState.unifiedCacheChars = [];
996
+ animateAllState.unifiedAudio = null;
997
+ animateAllState.completedIdx = -1;
998
+ animateAllState.active = false;
999
+ }
1000
+ // Fresh start
1001
+ document.querySelectorAll('.animate-btn.active').forEach(function(b) {
1002
+ toggleAnimation(b);
1003
+ });
1004
+ var result = buildMegaCard();
1005
+ if (!result || animateAllState.segments.length === 0) return;
1006
+ if (!result.unifiedAudio) {
1007
+ console.error('Animate All: unified audio not available');
1008
+ return;
1009
+ }
1010
+ var mega = result.mega;
1011
+ var topBar = result.topBar;
1012
+ animateAllState.megaCard = mega;
1013
+ animateAllState.topBar = topBar;
1014
+ animateAllState.unifiedAudio = result.unifiedAudio;
1015
+ animateAllState.btn = btn;
1016
+ animateAllState.active = true;
1017
+ // Hide individual segment cards
1018
+ document.querySelectorAll('.segments-container .segment-card').forEach(function(c) {
1019
+ c.classList.add('hidden-for-mega');
1020
+ });
1021
+ // Hide left column and expand right column to full width
1022
+ var leftCol = document.getElementById('left-col');
1023
+ if (leftCol) leftCol.style.display = 'none';
1024
+ var mainRow = document.getElementById('main-row');
1025
+ if (mainRow) {
1026
+ var rightCol = mainRow.querySelector(':scope > div:last-child');
1027
+ if (rightCol) {
1028
+ animateAllState.savedFlexGrow = rightCol.style.flexGrow;
1029
+ rightCol.style.flexGrow = '1';
1030
+ }
1031
+ }
1032
+ // Hide description and API accordion (everything before main-row)
1033
+ if (mainRow) {
1034
+ var sibling = mainRow.parentNode.firstElementChild;
1035
+ while (sibling && sibling !== mainRow) {
1036
+ sibling.style.display = 'none';
1037
+ sibling = sibling.nextElementSibling;
1038
+ }
1039
+ }
1040
+ // Hide header/summary text and action buttons
1041
+ document.querySelectorAll('.segments-header, .segments-review-summary').forEach(function(el) {
1042
+ el.style.display = 'none';
1043
+ });
1044
+ var actionRow = document.getElementById('action-btns-row');
1045
+ if (actionRow) actionRow.style.display = 'none';
1046
+ var tsRow = document.getElementById('ts-row');
1047
+ if (tsRow) tsRow.style.display = 'none';
1048
+ // Move animation settings accordion above segments container
1049
+ var animAccordion = document.getElementById('anim-settings-accordion');
1050
+ if (animAccordion) {
1051
+ animateAllState.accordionParent = animAccordion.parentNode;
1052
+ animateAllState.accordionNextSibling = animAccordion.nextElementSibling;
1053
+ var container = document.querySelector('.segments-container');
1054
+ if (container) container.parentNode.insertBefore(animAccordion, container);
1055
+ }
1056
+ // Show mega styling sliders (hidden in normal card view)
1057
+ var megaStylingRow = document.getElementById('mega-styling-row');
1058
+ if (megaStylingRow) megaStylingRow.style.display = 'flex';
1059
+ var container = document.querySelector('.segments-container');
1060
+ // Add tip callout above the mega card
1061
+ var tip = document.createElement('div');
1062
+ tip.className = 'mega-tip';
1063
+ tip.textContent = 'Tip: Click on any word to seek to it, or click a verse marker to jump to the start of that verse.';
1064
+ container.appendChild(topBar);
1065
+ container.appendChild(tip);
1066
+ container.appendChild(mega);
1067
+ // Append unified audio element to mega card (hidden)
1068
+ if (animateAllState.unifiedAudio) {
1069
+ mega.appendChild(animateAllState.unifiedAudio);
1070
+ }
1071
+ // Move Stop/Resume button into top bar (above mega card)
1072
+ btn.classList.add('active');
1073
+ btn.textContent = 'Stop';
1074
+ animateAllState.btnParent = btn.parentNode;
1075
+ topBar.appendChild(btn);
1076
+ // Start animation from first segment
1077
+ animateSegment(0);
1078
+ }
1079
+
1080
+ // Event delegation for Animate button clicks and click-to-seek
1081
+ document.addEventListener('click', function(e) {
1082
+ // Click-to-seek in Animate All mode
1083
+ if (animateAllState.active && animateAllState.megaCard) {
1084
+ var wordEl = e.target.closest('.word');
1085
+ if (wordEl && animateAllState.megaCard.contains(wordEl)) {
1086
+ seekToWord(wordEl);
1087
+ return;
1088
+ }
1089
+ var markerEl = e.target.closest('.verse-marker');
1090
+ if (markerEl && animateAllState.megaCard.contains(markerEl)) {
1091
+ seekToVerseMarker(markerEl);
1092
+ return;
1093
+ }
1094
+ }
1095
+ if (e.target.matches('.play-btn')) {
1096
+ var card = e.target.closest('.segment-card');
1097
+ var audio = card && card.querySelector('audio');
1098
+ if (audio) {
1099
+ activateAudio(audio);
1100
+ audio.play().catch(function(){});
1101
+ }
1102
+ }
1103
+ if (e.target.matches('.animate-btn')) {
1104
+ toggleAnimation(e.target);
1105
+ }
1106
+ if (e.target.matches('.animate-all-btn')) {
1107
+ toggleAnimateAll(e.target);
1108
+ }
1109
+ if (e.target.matches('.mega-exit-btn')) {
1110
+ stopAnimateAll();
1111
+ }
1112
+ });
1113
+
1114
+ // Clear highlights when audio ends (with delay so last word is visible)
1115
+ document.addEventListener('ended', function(e) {
1116
+ if (e.target.tagName === 'AUDIO') {
1117
+ var audio = e.target;
1118
+ // If Animate All is active with unified audio, handle full recording end
1119
+ if (animateAllState.active && audio === animateAllState.unifiedAudio) {
1120
+ // Unified audio ended — entire recording finished
1121
+ setTimeout(function() { stopAnimateAll(); }, 500);
1122
+ return;
1123
+ }
1124
+ // Default per-card behavior
1125
+ var card = audio.closest('.segment-card');
1126
+ if (card) {
1127
+ setTimeout(function() {
1128
+ var btn = card.querySelector('.animate-btn');
1129
+ if (btn && btn.classList.contains('active')) {
1130
+ btn.classList.remove('active');
1131
+ btn.textContent = 'Animate';
1132
+ stopAnimation(audio, card);
1133
+ }
1134
+ }, 500);
1135
+ }
1136
+ }
1137
+ }, true);
1138
+
1139
+ // Prefetch next segment's audio when current segment starts playing
1140
+ document.addEventListener('play', function(e) {
1141
+ if (e.target.tagName === 'AUDIO') {
1142
+ var card = e.target.closest('.segment-card');
1143
+ if (!card) return;
1144
+ var next = card.nextElementSibling;
1145
+ if (next && next.classList.contains('segment-card')) {
1146
+ var nextAudio = next.querySelector('audio');
1147
+ if (nextAudio && !nextAudio.src && nextAudio.dataset.src) {
1148
+ nextAudio.src = nextAudio.dataset.src;
1149
+ nextAudio.preload = 'metadata';
1150
+ }
1151
+ }
1152
+ }
1153
+ }, true);
src/ui/static/animation-core.js ADDED
@@ -0,0 +1,699 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =====================================================================
2
+ // Per-segment animation engine — audio warmup, caching, window mode,
3
+ // highlight tick loop, and per-card Animate toggle.
4
+ // Requires window.* config globals set by js_config.py.
5
+ // =====================================================================
6
+
7
+ // Warm up browser audio pipeline on first user interaction.
8
+ // Uses pointerdown (fires ~50-100ms before click) + AudioContext.resume()
9
+ // to prime the audio hardware before the <audio> element's play fires.
10
+ var _audioWarmedUp = false;
11
+ function _warmupAudio() {
12
+ if (_audioWarmedUp) return;
13
+ _audioWarmedUp = true;
14
+ // 1. Resume/create AudioContext — this is what actually initializes audio hardware
15
+ var ctx = new (window.AudioContext || window.webkitAudioContext)();
16
+ if (ctx.state === 'suspended') ctx.resume();
17
+ // Play a single silent sample to force full pipeline init
18
+ var buf = ctx.createBuffer(1, 1, 22050);
19
+ var src = ctx.createBufferSource();
20
+ src.buffer = buf;
21
+ src.connect(ctx.destination);
22
+ src.start();
23
+ // 2. Also prime HTML5 Audio path with a silent WAV
24
+ var a = new Audio('data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAABCxAgACABAAZGF0YQIAAAAAAA==');
25
+ a.volume = 0;
26
+ a.play().catch(function(){});
27
+ document.removeEventListener('pointerdown', _warmupAudio);
28
+ document.removeEventListener('touchstart', _warmupAudio);
29
+ }
30
+ // pointerdown fires before click, giving audio hardware a head start
31
+ document.addEventListener('pointerdown', _warmupAudio);
32
+ document.addEventListener('touchstart', _warmupAudio, {passive: true});
33
+
34
+ // --- localStorage persistence helpers ---
35
+ var _ANIM_STORAGE_KEY = 'quran_anim_settings';
36
+ function loadAnimSettings() {
37
+ try {
38
+ var raw = localStorage.getItem(_ANIM_STORAGE_KEY);
39
+ return raw ? JSON.parse(raw) : null;
40
+ } catch(e) { return null; }
41
+ }
42
+ function saveAnimSettings() {
43
+ try {
44
+ var mode = window.ANIM_DISPLAY_MODE;
45
+ var s = loadAnimSettings() || {};
46
+ s.granularity = window.ANIM_GRANULARITY;
47
+ s.mode = mode;
48
+ s.verseOnly = !!window.ANIM_VERSE_MODE;
49
+ s.color = getComputedStyle(document.documentElement).getPropertyValue('--anim-word-color').trim() || window.ANIM_WORD_COLOR_DEFAULT;
50
+ // Read text styling from slider DOM values (more reliable than mega-card inline styles)
51
+ var wsEl = document.querySelector('#anim-word-spacing input[type=range],#anim-word-spacing input[type=number]');
52
+ var tsEl = document.querySelector('#anim-text-size input[type=range],#anim-text-size input[type=number]');
53
+ var lsEl = document.querySelector('#anim-line-spacing input[type=range],#anim-line-spacing input[type=number]');
54
+ s.wordSpacing = wsEl ? parseFloat(wsEl.value) : window.MEGA_WORD_SPACING_DEFAULT;
55
+ s.textSize = tsEl ? parseFloat(tsEl.value) : window.MEGA_TEXT_SIZE_DEFAULT;
56
+ s.lineSpacing = lsEl ? parseFloat(lsEl.value) : window.MEGA_LINE_SPACING_DEFAULT;
57
+ // Always keep custom sub-object up to date when in Custom mode
58
+ if (mode === 'Custom') {
59
+ s.custom = {
60
+ prevOpacity: window.ANIM_OPACITY_PREV,
61
+ afterOpacity: window.ANIM_OPACITY_AFTER,
62
+ prevWords: window.ANIM_WINDOW_PREV,
63
+ afterWords: window.ANIM_WINDOW_AFTER
64
+ };
65
+ }
66
+ s.playbackRate = window.ANIM_PLAYBACK_RATE || 1;
67
+ localStorage.setItem(_ANIM_STORAGE_KEY, JSON.stringify(s));
68
+ } catch(e) {}
69
+ }
70
+
71
+ // --- Restore from localStorage or use defaults ---
72
+ var _saved = loadAnimSettings();
73
+ if (_saved) {
74
+ window.ANIM_DISPLAY_MODE = _saved.mode || window.ANIM_DISPLAY_MODE_DEFAULT;
75
+ window.ANIM_GRANULARITY = _saved.granularity || window.ANIM_GRANULARITY_DEFAULT;
76
+ window.ANIM_VERSE_MODE = !!_saved.verseOnly;
77
+ if (_saved.color) document.documentElement.style.setProperty('--anim-word-color', _saved.color);
78
+ var _rPreset = window.ANIM_PRESETS[window.ANIM_DISPLAY_MODE];
79
+ if (_rPreset) {
80
+ window.ANIM_OPACITY_PREV = _rPreset.prev_opacity;
81
+ window.ANIM_OPACITY_AFTER = _rPreset.after_opacity;
82
+ window.ANIM_WINDOW_PREV = _rPreset.prev_words;
83
+ window.ANIM_WINDOW_AFTER = _rPreset.after_words;
84
+ } else if (_saved.custom) {
85
+ window.ANIM_OPACITY_PREV = _saved.custom.prevOpacity;
86
+ window.ANIM_OPACITY_AFTER = _saved.custom.afterOpacity;
87
+ window.ANIM_WINDOW_PREV = _saved.custom.prevWords;
88
+ window.ANIM_WINDOW_AFTER = _saved.custom.afterWords;
89
+ } else {
90
+ window.ANIM_OPACITY_PREV = window.ANIM_OPACITY_PREV_DEFAULT;
91
+ window.ANIM_OPACITY_AFTER = window.ANIM_OPACITY_AFTER_DEFAULT;
92
+ window.ANIM_WINDOW_PREV = window.ANIM_WINDOW_PREV_DEFAULT;
93
+ window.ANIM_WINDOW_AFTER = window.ANIM_WINDOW_AFTER_DEFAULT;
94
+ }
95
+ } else {
96
+ window.ANIM_DISPLAY_MODE = window.ANIM_DISPLAY_MODE_DEFAULT;
97
+ window.ANIM_GRANULARITY = window.ANIM_GRANULARITY_DEFAULT;
98
+ var _initPreset = window.ANIM_PRESETS[window.ANIM_DISPLAY_MODE_DEFAULT];
99
+ window.ANIM_OPACITY_PREV = _initPreset ? _initPreset.prev_opacity : window.ANIM_OPACITY_PREV_DEFAULT;
100
+ window.ANIM_OPACITY_AFTER = _initPreset ? _initPreset.after_opacity : window.ANIM_OPACITY_AFTER_DEFAULT;
101
+ window.ANIM_WINDOW_PREV = _initPreset ? _initPreset.prev_words : window.ANIM_WINDOW_PREV_DEFAULT;
102
+ window.ANIM_WINDOW_AFTER = _initPreset ? _initPreset.after_words : window.ANIM_WINDOW_AFTER_DEFAULT;
103
+ window.ANIM_VERSE_MODE = false;
104
+ }
105
+ window.ANIM_PLAYBACK_RATE = (_saved && _saved.playbackRate) ? _saved.playbackRate : 1;
106
+ window._windowPrevGradient = [];
107
+ window._windowAfterGradient = [];
108
+
109
+ function rebuildWindowGradient() {
110
+ var basePrev = window.ANIM_OPACITY_PREV;
111
+ var baseAfter = window.ANIM_OPACITY_AFTER;
112
+ var pc = window.ANIM_WINDOW_PREV;
113
+ var ac = window.ANIM_WINDOW_AFTER;
114
+ window._windowPrevGradient = [];
115
+ window._windowAfterGradient = [];
116
+ // At max: empty gradient signals "show all at flat base opacity"
117
+ if (pc < window.ANIM_WINDOW_PREV_MAX) {
118
+ for (var d = 1; d <= pc; d++) {
119
+ window._windowPrevGradient.push(String(basePrev * (pc - d + 1) / pc));
120
+ }
121
+ }
122
+ if (ac < window.ANIM_WINDOW_AFTER_MAX) {
123
+ for (var d = 1; d <= ac; d++) {
124
+ window._windowAfterGradient.push(String(baseAfter * (ac - d + 1) / ac));
125
+ }
126
+ }
127
+ }
128
+ rebuildWindowGradient();
129
+
130
+ // Activate a lazy-loaded audio element (set src, show controls, hide play button)
131
+ function activateAudio(audio) {
132
+ if (audio.hasAttribute('controls')) return audio;
133
+ audio.src = audio.dataset.src;
134
+ audio.controls = true;
135
+ audio.style.display = '';
136
+ var playBtn = audio.parentElement && audio.parentElement.querySelector('.play-btn');
137
+ if (playBtn) playBtn.style.display = 'none';
138
+ return audio;
139
+ }
140
+
141
+ // =====================================================================
142
+ // Animation Debug Logging
143
+ // Enable via: window.ANIM_DEBUG = true; in browser console
144
+ // =====================================================================
145
+ window.ANIM_DEBUG = false; // Set to true to enable animation debug logging
146
+ function animDebug(category, msg, data) {
147
+ if (!window.ANIM_DEBUG) return;
148
+ var prefix = '[ANIM:' + category + ']';
149
+ if (data !== undefined) {
150
+ console.log(prefix, msg, data);
151
+ } else {
152
+ console.log(prefix, msg);
153
+ }
154
+ }
155
+ function dumpCacheTimestamps(cache, label) {
156
+ if (!window.ANIM_DEBUG) return;
157
+ console.group('[ANIM:CACHE] ' + label + ' (' + cache.length + ' entries)');
158
+ cache.forEach(function(item, idx) {
159
+ var text = item.el.textContent.substring(0, 20);
160
+ var gid = item.groupId || '-';
161
+ console.log(idx + ': "' + text + '" start=' + item.start.toFixed(3) + ' end=' + item.end.toFixed(3) + ' groupId=' + gid);
162
+ });
163
+ if (cache._groupIndex) {
164
+ console.log('_groupIndex:', JSON.parse(JSON.stringify(cache._groupIndex)));
165
+ }
166
+ console.groupEnd();
167
+ }
168
+
169
+ // Cache elements and timing data for a given selector
170
+ // Also builds group index for letter groups (chars with same data-group-id)
171
+ function initCacheFor(card, selector) {
172
+ var elements = Array.from(card.querySelectorAll(selector));
173
+ var cache = elements.map(function(el, idx) {
174
+ return {
175
+ el: el,
176
+ start: parseFloat(el.dataset.start),
177
+ end: parseFloat(el.dataset.end),
178
+ groupId: el.dataset.groupId || null,
179
+ cacheIdx: idx
180
+ };
181
+ });
182
+ // Build group index: groupId → [cacheIdx, ...]
183
+ var groupIndex = {};
184
+ cache.forEach(function(item) {
185
+ if (item.groupId) {
186
+ if (!groupIndex[item.groupId]) {
187
+ groupIndex[item.groupId] = [];
188
+ }
189
+ groupIndex[item.groupId].push(item.cacheIdx);
190
+ }
191
+ });
192
+ cache._groupIndex = groupIndex;
193
+ animDebug('INIT', 'initCacheFor("' + selector + '"): ' + cache.length + ' elements, ' + Object.keys(groupIndex).length + ' groups');
194
+ return cache;
195
+ }
196
+
197
+ // Apply class to element and all members of its letter group
198
+ function applyClassToGroup(cache, idx, className, add) {
199
+ var item = cache[idx];
200
+ if (!item) {
201
+ animDebug('CLASS', 'applyClassToGroup SKIP: no item at idx=' + idx);
202
+ return;
203
+ }
204
+ var text = item.el.textContent.substring(0, 10);
205
+ animDebug('CLASS', (add ? '+' : '-') + className + ' idx=' + idx + ' "' + text + '" groupId=' + (item.groupId || '-'));
206
+ if (add) {
207
+ item.el.classList.add(className);
208
+ } else {
209
+ item.el.classList.remove(className);
210
+ }
211
+ // Also apply to all group members if this element has a groupId
212
+ if (item.groupId && cache._groupIndex) {
213
+ var groupMembers = cache._groupIndex[item.groupId] || [];
214
+ if (groupMembers.length > 1) {
215
+ animDebug('CLASS', ' -> propagating to group members:', groupMembers);
216
+ }
217
+ groupMembers.forEach(function(memberIdx) {
218
+ if (memberIdx !== idx) {
219
+ if (add) {
220
+ cache[memberIdx].el.classList.add(className);
221
+ } else {
222
+ cache[memberIdx].el.classList.remove(className);
223
+ }
224
+ }
225
+ });
226
+ }
227
+ }
228
+
229
+ // Return the active cache based on current granularity setting
230
+ function getActiveCache(audio) {
231
+ return window.ANIM_GRANULARITY === 'Characters' ? audio._cacheChars : audio._cacheWords;
232
+ }
233
+
234
+ // Apply opacity to element and all members of its letter group
235
+ function applyOpacityToGroup(cache, idx, opacity) {
236
+ var item = cache[idx];
237
+ if (!item) {
238
+ animDebug('OPACITY', 'applyOpacityToGroup SKIP: no item at idx=' + idx);
239
+ return;
240
+ }
241
+ var text = item.el.textContent.substring(0, 10);
242
+ animDebug('OPACITY', 'idx=' + idx + ' "' + text + '" opacity=' + (opacity === null ? 'CLEAR' : opacity) + ' groupId=' + (item.groupId || '-'));
243
+ if (opacity === null) {
244
+ item.el.style.removeProperty('opacity');
245
+ } else {
246
+ item.el.style.opacity = opacity;
247
+ }
248
+ // Also apply to all group members if this element has a groupId
249
+ if (item.groupId && cache._groupIndex) {
250
+ var groupMembers = cache._groupIndex[item.groupId] || [];
251
+ if (groupMembers.length > 1) {
252
+ animDebug('OPACITY', ' -> propagating to group members:', groupMembers);
253
+ }
254
+ groupMembers.forEach(function(memberIdx) {
255
+ if (memberIdx !== idx) {
256
+ if (opacity === null) {
257
+ cache[memberIdx].el.style.removeProperty('opacity');
258
+ } else {
259
+ cache[memberIdx].el.style.opacity = opacity;
260
+ }
261
+ }
262
+ });
263
+ }
264
+ }
265
+
266
+ // Build group index for a cache array (extracts groupId and builds _groupIndex)
267
+ // Used for megacard caches which are built manually without initCacheFor()
268
+ function buildGroupIndex(cache) {
269
+ var groupIndex = {};
270
+ cache.forEach(function(item, idx) {
271
+ var gid = item.el.dataset.groupId;
272
+ if (gid) {
273
+ item.groupId = gid;
274
+ if (!groupIndex[gid]) groupIndex[gid] = [];
275
+ groupIndex[gid].push(idx);
276
+ }
277
+ });
278
+ cache._groupIndex = groupIndex;
279
+ var groupCount = Object.keys(groupIndex).length;
280
+ animDebug('GROUP', 'buildGroupIndex: ' + cache.length + ' elements, ' + groupCount + ' groups');
281
+ if (window.ANIM_DEBUG && groupCount > 0) {
282
+ for (var gid in groupIndex) {
283
+ if (groupIndex[gid].length > 1) {
284
+ animDebug('GROUP', ' group "' + gid + '": indices ' + JSON.stringify(groupIndex[gid]));
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ // Window mode: track active state for live slider updates
291
+ window._windowActiveCache = null;
292
+ window._windowActiveIdx = -1;
293
+ window._windowLastPc = 0;
294
+ window._windowLastAc = 0;
295
+ window._windowLastPcAll = false;
296
+ window._windowLastAcAll = false;
297
+ window._windowSettingsVersion = 0; // Incremented when sliders change, so tick() can detect
298
+
299
+ // Window mode: apply per-element opacity gradient around active index
300
+ function applyWindowOpacity(cache, newIdx, prevIdx) {
301
+ animDebug('WINDOW', 'applyWindowOpacity newIdx=' + newIdx + ' prevIdx=' + prevIdx + ' cacheLen=' + cache.length);
302
+ // If prevIdx is unknown (e.g. after a timing gap between words),
303
+ // fall back to the last index we applied so the old window gets cleared.
304
+ if (prevIdx < 0 && window._windowActiveIdx >= 0 && window._windowActiveCache === cache) {
305
+ animDebug('WINDOW', ' -> using fallback prevIdx=' + window._windowActiveIdx);
306
+ prevIdx = window._windowActiveIdx;
307
+ }
308
+ var prevGrad = window._windowPrevGradient;
309
+ var afterGrad = window._windowAfterGradient;
310
+ var pc = prevGrad.length;
311
+ var ac = afterGrad.length;
312
+ var pcAll = (window.ANIM_WINDOW_PREV >= window.ANIM_WINDOW_PREV_MAX);
313
+ var acAll = (window.ANIM_WINDOW_AFTER >= window.ANIM_WINDOW_AFTER_MAX);
314
+ var oldPcAll = window._windowLastPcAll;
315
+ var oldAcAll = window._windowLastAcAll;
316
+ var basePrev = String(window.ANIM_OPACITY_PREV);
317
+ var baseAfter = String(window.ANIM_OPACITY_AFTER);
318
+ animDebug('WINDOW', ' verseMode=' + window.ANIM_VERSE_MODE + ' pcAll=' + pcAll + ' acAll=' + acAll + ' basePrev=' + basePrev + ' baseAfter=' + baseAfter);
319
+ // Verse mode: show only current-verse words, hide everything else
320
+ if (window.ANIM_VERSE_MODE) {
321
+ var activeEl = cache[newIdx].el;
322
+ var activePos = activeEl.dataset.pos || (activeEl.closest('.word') || {}).dataset?.pos || '';
323
+ var vp = activePos.split(':');
324
+ var activeVerse = vp.length >= 2 ? vp[0] + ':' + vp[1] : '';
325
+ // Track which group IDs we've already handled to avoid duplicates
326
+ var handledGroups = {};
327
+ for (var i = 0; i < cache.length; i++) {
328
+ // Skip if this element's group was already handled
329
+ var gid = cache[i].groupId;
330
+ if (gid && handledGroups[gid]) continue;
331
+ if (gid) handledGroups[gid] = true;
332
+ if (i === newIdx) {
333
+ applyOpacityToGroup(cache, i, null);
334
+ continue;
335
+ }
336
+ var el = cache[i].el;
337
+ var pos = el.dataset.pos || (el.closest('.word') || {}).dataset?.pos || '';
338
+ var wp = pos.split(':');
339
+ var wverse = wp.length >= 2 ? wp[0] + ':' + wp[1] : '';
340
+ if (wverse === activeVerse) {
341
+ // Same verse: normal gradient opacity
342
+ if (i < newIdx || !cache[i].el.classList.contains('reached')) {
343
+ applyOpacityToGroup(cache, i, (i < newIdx) ? basePrev : baseAfter);
344
+ }
345
+ } else if (i < newIdx) {
346
+ // Different verse, before current: always hide past verses
347
+ applyOpacityToGroup(cache, i, '0');
348
+ } else {
349
+ // Different verse, after current: hide future verses in verse-only mode
350
+ applyOpacityToGroup(cache, i, '0');
351
+ }
352
+ }
353
+ window._windowActiveCache = cache;
354
+ window._windowActiveIdx = newIdx;
355
+ return;
356
+ }
357
+ // Fast path: All→All steady state — only 2 elements change (with group support)
358
+ if (prevIdx >= 0 && newIdx >= 0 && pcAll && acAll && oldPcAll && oldAcAll) {
359
+ applyOpacityToGroup(cache, prevIdx, basePrev);
360
+ applyOpacityToGroup(cache, newIdx, null);
361
+ window._windowActiveCache = cache;
362
+ window._windowActiveIdx = newIdx;
363
+ window._windowLastPc = pc; window._windowLastAc = ac;
364
+ window._windowLastPcAll = pcAll; window._windowLastAcAll = acAll;
365
+ return;
366
+ }
367
+ // Clear old window range
368
+ if (prevIdx >= 0) {
369
+ if (oldPcAll || oldAcAll) {
370
+ // Previous state had "all" — clear every element
371
+ for (var i = 0; i < cache.length; i++) {
372
+ cache[i].el.style.removeProperty('opacity');
373
+ }
374
+ } else {
375
+ var clearPc = Math.max(pc, window._windowLastPc);
376
+ var clearAc = Math.max(ac, window._windowLastAc);
377
+ var clearStart = Math.max(0, prevIdx - clearPc);
378
+ var clearEnd = Math.min(cache.length - 1, prevIdx + clearAc);
379
+ for (var i = clearStart; i <= clearEnd; i++) {
380
+ cache[i].el.style.removeProperty('opacity');
381
+ }
382
+ }
383
+ }
384
+ // Track state for live slider updates
385
+ window._windowActiveCache = cache;
386
+ window._windowActiveIdx = newIdx;
387
+ window._windowLastPc = pc;
388
+ window._windowLastAc = ac;
389
+ window._windowLastPcAll = pcAll;
390
+ window._windowLastAcAll = acAll;
391
+ if (newIdx < 0) return;
392
+ // Apply previous elements (with group support)
393
+ if (pcAll) {
394
+ for (var i = 0; i < newIdx; i++) {
395
+ applyOpacityToGroup(cache, i, basePrev);
396
+ }
397
+ } else {
398
+ for (var p = 0; p < pc; p++) {
399
+ var idx = newIdx - (p + 1);
400
+ if (idx < 0) break;
401
+ applyOpacityToGroup(cache, idx, prevGrad[p]);
402
+ }
403
+ }
404
+ // Apply after elements (upcoming words always get opacity set for
405
+ // proper word-by-word animation, even if they have 'reached' from
406
+ // a previous segment) — with group support
407
+ if (acAll) {
408
+ for (var i = newIdx + 1; i < cache.length; i++) {
409
+ applyOpacityToGroup(cache, i, baseAfter);
410
+ }
411
+ } else {
412
+ for (var a = 0; a < ac; a++) {
413
+ var idx = newIdx + (a + 1);
414
+ if (idx >= cache.length) break;
415
+ applyOpacityToGroup(cache, idx, afterGrad[a]);
416
+ }
417
+ }
418
+ // Fade previously-active word from full opacity to its new level (with group support)
419
+ if (prevIdx >= 0 && prevIdx !== newIdx) {
420
+ var tgt = cache[prevIdx].el.style.opacity || '0';
421
+ applyOpacityToGroup(cache, prevIdx, tgt);
422
+ }
423
+ // Reconcile group opacities: grouped characters should appear as
424
+ // one visual unit, using the max opacity from any member
425
+ if (cache._groupIndex) {
426
+ var gids = Object.keys(cache._groupIndex);
427
+ for (var g = 0; g < gids.length; g++) {
428
+ var members = cache._groupIndex[gids[g]];
429
+ if (members.length <= 1) continue;
430
+ // If any member is active, set all to full opacity
431
+ var anyActive = false;
432
+ var maxOp = -1;
433
+ for (var m = 0; m < members.length; m++) {
434
+ if (cache[members[m]].el.classList.contains('active')) {
435
+ anyActive = true;
436
+ break;
437
+ }
438
+ var op = cache[members[m]].el.style.opacity;
439
+ if (op !== '') {
440
+ var val = parseFloat(op);
441
+ if (!isNaN(val) && val > maxOp) maxOp = val;
442
+ }
443
+ }
444
+ if (anyActive) {
445
+ for (var m = 0; m < members.length; m++) {
446
+ cache[members[m]].el.style.opacity = '1';
447
+ }
448
+ } else if (maxOp > 0) {
449
+ var maxOpStr = String(maxOp);
450
+ for (var m = 0; m < members.length; m++) {
451
+ cache[members[m]].el.style.opacity = maxOpStr;
452
+ }
453
+ }
454
+ // maxOp <= 0: group is hidden (outside window), leave as-is
455
+ }
456
+ }
457
+ }
458
+
459
+ // Re-apply window opacity immediately (called when sliders change mid-animation)
460
+ function reapplyWindowNow() {
461
+ var cache = window._windowActiveCache;
462
+ var idx = window._windowActiveIdx;
463
+ if (!cache || idx < 0) return;
464
+ applyWindowOpacity(cache, idx, idx);
465
+ }
466
+
467
+ // Replace numeric value with "All" when slider is at maximum
468
+ function updateWindowMaxLabel(elemId, val, maxVal) {
469
+ var el = document.getElementById(elemId);
470
+ if (!el) return;
471
+ var numInput = el.querySelector('input[type="number"]');
472
+ if (!numInput) return;
473
+ if (val >= maxVal) {
474
+ numInput.style.display = 'none';
475
+ var maxSpan = el.querySelector('.max-label');
476
+ if (!maxSpan) {
477
+ maxSpan = document.createElement('span');
478
+ maxSpan.className = 'max-label';
479
+ maxSpan.style.cssText = 'font-weight: bold; opacity: 0.85;';
480
+ numInput.parentNode.insertBefore(maxSpan, numInput.nextSibling);
481
+ }
482
+ maxSpan.textContent = 'All';
483
+ maxSpan.style.display = '';
484
+ } else {
485
+ numInput.style.display = '';
486
+ var maxSpan = el.querySelector('.max-label');
487
+ if (maxSpan) maxSpan.style.display = 'none';
488
+ }
489
+ // Always inject a hint at the right end of the slider track
490
+ if (!el.querySelector('.max-hint')) {
491
+ var rangeWrap = el.querySelector('input[type="range"]');
492
+ if (rangeWrap) {
493
+ var hint = document.createElement('span');
494
+ hint.className = 'max-hint';
495
+ hint.textContent = 'All';
496
+ hint.style.cssText = 'position: absolute; right: 0; bottom: -1.2em; font-size: 0.7em; opacity: 0.5;';
497
+ var parent = rangeWrap.parentNode;
498
+ if (parent) {
499
+ parent.style.position = 'relative';
500
+ parent.appendChild(hint);
501
+ }
502
+ }
503
+ }
504
+ }
505
+
506
+ // Expose to global scope so Gradio inline js= callbacks can call them
507
+ window.rebuildWindowGradient = rebuildWindowGradient;
508
+ window.reapplyWindowNow = reapplyWindowNow;
509
+ window.updateWindowMaxLabel = updateWindowMaxLabel;
510
+ window.saveAnimSettings = saveAnimSettings;
511
+ window.loadAnimSettings = loadAnimSettings;
512
+
513
+ // Inject "All" hints on slider tracks once Gradio renders them
514
+ setTimeout(function() {
515
+ updateWindowMaxLabel('anim-window-prev', window.ANIM_WINDOW_PREV, window.ANIM_WINDOW_PREV_MAX);
516
+ updateWindowMaxLabel('anim-window-after', window.ANIM_WINDOW_AFTER, window.ANIM_WINDOW_AFTER_MAX);
517
+ }, 500);
518
+
519
+ // Clear inline opacity from all words/chars in a card (Window mode cleanup)
520
+ // Applies mode's prev_opacity instead of removing opacity entirely
521
+ function clearWindowOpacity(card) {
522
+ var prevOp = window.ANIM_OPACITY_PREV;
523
+ card.querySelectorAll('.word, .char').forEach(function(el) {
524
+ // Apply mode's prev_opacity consistently:
525
+ // - Reveal/Fade (1.0): full visibility
526
+ // - Spotlight (0.3): dimmed
527
+ // - Isolate/Consume (0): hidden/disappear
528
+ if (prevOp >= 1) {
529
+ el.style.removeProperty('opacity');
530
+ } else {
531
+ el.style.opacity = String(prevOp);
532
+ }
533
+ });
534
+ }
535
+
536
+ function clearHighlights(card) {
537
+ card.querySelectorAll('.word.active, .word.reached, .char.active, .char.reached').forEach(function(w) {
538
+ w.classList.remove('active', 'reached');
539
+ });
540
+ clearWindowOpacity(card);
541
+ card.classList.remove('anim-window', 'anim-chars');
542
+ }
543
+
544
+ function stopAnimation(audio, card) {
545
+ if (audio._rafId) {
546
+ cancelAnimationFrame(audio._rafId);
547
+ audio._rafId = null;
548
+ }
549
+ if (card) {
550
+ // Apply mode's prev_opacity to last active word before clearing
551
+ var activeEl = card.querySelector('.word.active, .char.active');
552
+ if (activeEl && window.ANIM_OPACITY_PREV < 1) {
553
+ activeEl.style.opacity = String(window.ANIM_OPACITY_PREV);
554
+ }
555
+ clearHighlights(card);
556
+ }
557
+ }
558
+
559
+ function startAnimation(audio, card) {
560
+ var lastWordIdx = -1;
561
+ var lastGranularity = window.ANIM_GRANULARITY;
562
+ var lastOpacityPrev = window.ANIM_OPACITY_PREV;
563
+ var lastSeenVersion = window._windowSettingsVersion;
564
+ // Segment audio is trimmed, so currentTime starts at 0.
565
+ // Word timestamps are absolute, so we need to add segment offset.
566
+ var segOffset = parseFloat(card.dataset.startTime) || 0;
567
+
568
+ function tick() {
569
+ if (audio.paused || audio.ended) return;
570
+ var wordCache = getActiveCache(audio);
571
+ var currentTime = audio.currentTime + segOffset;
572
+
573
+ // Granularity switched mid-animation — clear old highlights and reset
574
+ if (window.ANIM_GRANULARITY !== lastGranularity) {
575
+ animDebug('TICK', 'Granularity changed: ' + lastGranularity + ' -> ' + window.ANIM_GRANULARITY);
576
+ card.querySelectorAll('.word.active, .word.reached, .char.active, .char.reached').forEach(function(w) {
577
+ w.classList.remove('active', 'reached');
578
+ });
579
+ clearWindowOpacity(card);
580
+ lastWordIdx = -1;
581
+ lastGranularity = window.ANIM_GRANULARITY;
582
+ }
583
+
584
+ // Mode changed mid-animation — refresh all reached words with new opacity
585
+ if (window.ANIM_OPACITY_PREV !== lastOpacityPrev) {
586
+ animDebug('TICK', 'Mode changed: prevOp ' + lastOpacityPrev + ' -> ' + window.ANIM_OPACITY_PREV);
587
+ card.querySelectorAll('.word.reached, .char.reached').forEach(function(el) {
588
+ if (window.ANIM_OPACITY_PREV >= 1) {
589
+ el.style.removeProperty('opacity');
590
+ } else {
591
+ el.style.opacity = String(window.ANIM_OPACITY_PREV);
592
+ }
593
+ });
594
+ lastOpacityPrev = window.ANIM_OPACITY_PREV;
595
+ }
596
+
597
+ // Slider settings changed mid-animation — reapply window opacity
598
+ if (window._windowSettingsVersion !== lastSeenVersion) {
599
+ if (lastWordIdx >= 0) {
600
+ applyWindowOpacity(wordCache, lastWordIdx, lastWordIdx);
601
+ }
602
+ lastSeenVersion = window._windowSettingsVersion;
603
+ }
604
+
605
+ var newWordIdx = -1;
606
+ var searchPath = '';
607
+ // Fast path: check current word, then next (covers ~99% of frames)
608
+ if (lastWordIdx >= 0 && lastWordIdx < wordCache.length &&
609
+ currentTime >= wordCache[lastWordIdx].start && currentTime < wordCache[lastWordIdx].end) {
610
+ newWordIdx = lastWordIdx;
611
+ searchPath = 'same';
612
+ } else if (lastWordIdx + 1 < wordCache.length &&
613
+ currentTime >= wordCache[lastWordIdx + 1].start && currentTime < wordCache[lastWordIdx + 1].end) {
614
+ newWordIdx = lastWordIdx + 1;
615
+ searchPath = 'next';
616
+ } else {
617
+ // Fallback: full scan (seeking, granularity switch, etc.)
618
+ searchPath = 'scan';
619
+ for (var i = 0; i < wordCache.length; i++) {
620
+ if (currentTime >= wordCache[i].start && currentTime < wordCache[i].end) {
621
+ newWordIdx = i;
622
+ break;
623
+ }
624
+ }
625
+ // Clamp to last word when past its end but audio hasn't ended yet
626
+ if (newWordIdx === -1 && wordCache.length > 0 && currentTime >= wordCache[wordCache.length - 1].start) {
627
+ newWordIdx = wordCache.length - 1;
628
+ searchPath = 'clamp';
629
+ }
630
+ }
631
+
632
+ // Only update DOM if word changed
633
+ if (newWordIdx !== lastWordIdx) {
634
+ var newText = newWordIdx >= 0 ? wordCache[newWordIdx].el.textContent.substring(0, 15) : '-';
635
+ animDebug('TICK', 'idx change: ' + lastWordIdx + ' -> ' + newWordIdx + ' (' + searchPath + ') t=' + currentTime.toFixed(3) + ' "' + newText + '"');
636
+ if (newWordIdx === -1 && wordCache.length > 0) {
637
+ // No match - log surrounding timing info
638
+ var first = wordCache[0];
639
+ var last = wordCache[wordCache.length - 1];
640
+ animDebug('TICK', ' NO MATCH: t=' + currentTime.toFixed(3) + ' cache[0]=[' + first.start.toFixed(3) + ',' + first.end.toFixed(3) + '] cache[' + (wordCache.length-1) + ']=[' + last.start.toFixed(3) + ',' + last.end.toFixed(3) + ']');
641
+ }
642
+ if (lastWordIdx >= 0 && lastWordIdx < wordCache.length) {
643
+ applyClassToGroup(wordCache, lastWordIdx, 'active', false);
644
+ applyClassToGroup(wordCache, lastWordIdx, 'reached', true);
645
+ }
646
+ if (newWordIdx >= 0) {
647
+ applyClassToGroup(wordCache, newWordIdx, 'active', true);
648
+ if (lastWordIdx === -1) {
649
+ // First highlight — catch up any skipped words (with group support)
650
+ for (var j = 0; j < newWordIdx; j++) {
651
+ applyClassToGroup(wordCache, j, 'reached', true);
652
+ }
653
+ }
654
+ }
655
+ if (newWordIdx >= 0) {
656
+ applyWindowOpacity(wordCache, newWordIdx, lastWordIdx);
657
+ }
658
+ lastWordIdx = newWordIdx;
659
+ }
660
+ }
661
+
662
+ function rafLoop() {
663
+ tick();
664
+ if (!audio.paused && !audio.ended) {
665
+ audio._rafId = requestAnimationFrame(rafLoop);
666
+ }
667
+ }
668
+ audio._rafId = requestAnimationFrame(rafLoop);
669
+ }
670
+
671
+ function toggleAnimation(btn) {
672
+ var card = btn.closest('.segment-card');
673
+ if (!card) return;
674
+ var audio = card.querySelector('audio');
675
+ if (!audio) return;
676
+
677
+ var isActive = btn.classList.toggle('active');
678
+ if (isActive) {
679
+ btn.textContent = 'Stop';
680
+ // Apply window engine class to card
681
+ card.classList.add('anim-window');
682
+ if (window.ANIM_GRANULARITY === 'Characters') {
683
+ card.classList.add('anim-chars');
684
+ }
685
+ // Build both caches upfront for live granularity switching
686
+ audio._cacheWords = initCacheFor(card, '.segment-text .word');
687
+ audio._cacheChars = initCacheFor(card, '.segment-text .char');
688
+ animDebug('START', 'toggleAnimation: words=' + audio._cacheWords.length + ' chars=' + audio._cacheChars.length + ' granularity=' + window.ANIM_GRANULARITY);
689
+ dumpCacheTimestamps(audio._cacheWords, 'Words');
690
+ dumpCacheTimestamps(audio._cacheChars, 'Chars');
691
+ activateAudio(audio);
692
+ startAnimation(audio, card);
693
+ audio.play();
694
+ } else {
695
+ btn.textContent = 'Animate';
696
+ audio.pause();
697
+ stopAnimation(audio, card);
698
+ }
699
+ }