quranic-universal-aligner / src /ui /static /animation-core.js
hetchyy's picture
Persist accordion open/closed state separately for main view and Animate All
ef9d3f2
// =====================================================================
// Per-segment animation engine — audio warmup, caching, window mode,
// highlight tick loop, and per-card Animate toggle.
// Requires window.* config globals set by js_config.py.
// =====================================================================
// Warm up browser audio pipeline on first user interaction.
// Uses pointerdown (fires ~50-100ms before click) + AudioContext.resume()
// to prime the audio hardware before the <audio> element's play fires.
var _audioWarmedUp = false;
function _warmupAudio() {
if (_audioWarmedUp) return;
_audioWarmedUp = true;
// 1. Resume/create AudioContext — this is what actually initializes audio hardware
var ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume();
// Play a single silent sample to force full pipeline init
var buf = ctx.createBuffer(1, 1, 22050);
var src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start();
// 2. Also prime HTML5 Audio path with a silent WAV
var a = new Audio('data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAABCxAgACABAAZGF0YQIAAAAAAA==');
a.volume = 0;
a.play().catch(function(){});
document.removeEventListener('pointerdown', _warmupAudio);
document.removeEventListener('touchstart', _warmupAudio);
}
// pointerdown fires before click, giving audio hardware a head start
document.addEventListener('pointerdown', _warmupAudio);
document.addEventListener('touchstart', _warmupAudio, {passive: true});
// --- localStorage persistence helpers ---
var _ANIM_STORAGE_KEY = 'quran_anim_settings';
function loadAnimSettings() {
try {
var raw = localStorage.getItem(_ANIM_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch(e) { return null; }
}
function saveAnimSettings() {
try {
var mode = window.ANIM_DISPLAY_MODE;
var s = loadAnimSettings() || {};
s.granularity = window.ANIM_GRANULARITY;
s.mode = mode;
s.verseOnly = !!window.ANIM_VERSE_MODE;
s.color = getComputedStyle(document.documentElement).getPropertyValue('--anim-word-color').trim() || window.ANIM_WORD_COLOR_DEFAULT;
// Read text styling from slider DOM values (more reliable than mega-card inline styles)
var wsEl = document.querySelector('#anim-word-spacing input[type=range],#anim-word-spacing input[type=number]');
var tsEl = document.querySelector('#anim-text-size input[type=range],#anim-text-size input[type=number]');
var lsEl = document.querySelector('#anim-line-spacing input[type=range],#anim-line-spacing input[type=number]');
s.wordSpacing = wsEl ? parseFloat(wsEl.value) : window.MEGA_WORD_SPACING_DEFAULT;
s.textSize = tsEl ? parseFloat(tsEl.value) : window.MEGA_TEXT_SIZE_DEFAULT;
s.lineSpacing = lsEl ? parseFloat(lsEl.value) : window.MEGA_LINE_SPACING_DEFAULT;
// Always keep custom sub-object up to date when in Custom mode
if (mode === 'Custom') {
s.custom = {
prevOpacity: window.ANIM_OPACITY_PREV,
afterOpacity: window.ANIM_OPACITY_AFTER,
prevWords: window.ANIM_WINDOW_PREV,
afterWords: window.ANIM_WINDOW_AFTER
};
}
s.playbackRate = window.ANIM_PLAYBACK_RATE || 1;
var accEl = document.getElementById('anim-settings-accordion');
if (accEl) {
var accOpen = accEl.classList.contains('open');
if (window._animateAllActive) s.accordionOpenMega = accOpen;
else s.accordionOpenMain = accOpen;
}
localStorage.setItem(_ANIM_STORAGE_KEY, JSON.stringify(s));
} catch(e) {}
}
// --- Restore from localStorage or use defaults ---
var _saved = loadAnimSettings();
if (_saved) {
window.ANIM_DISPLAY_MODE = _saved.mode || window.ANIM_DISPLAY_MODE_DEFAULT;
window.ANIM_GRANULARITY = _saved.granularity || window.ANIM_GRANULARITY_DEFAULT;
window.ANIM_VERSE_MODE = !!_saved.verseOnly;
if (_saved.color) document.documentElement.style.setProperty('--anim-word-color', _saved.color);
var _rPreset = window.ANIM_PRESETS[window.ANIM_DISPLAY_MODE];
if (_rPreset) {
window.ANIM_OPACITY_PREV = _rPreset.prev_opacity;
window.ANIM_OPACITY_AFTER = _rPreset.after_opacity;
window.ANIM_WINDOW_PREV = _rPreset.prev_words;
window.ANIM_WINDOW_AFTER = _rPreset.after_words;
} else if (_saved.custom) {
window.ANIM_OPACITY_PREV = _saved.custom.prevOpacity;
window.ANIM_OPACITY_AFTER = _saved.custom.afterOpacity;
window.ANIM_WINDOW_PREV = _saved.custom.prevWords;
window.ANIM_WINDOW_AFTER = _saved.custom.afterWords;
} else {
window.ANIM_OPACITY_PREV = window.ANIM_OPACITY_PREV_DEFAULT;
window.ANIM_OPACITY_AFTER = window.ANIM_OPACITY_AFTER_DEFAULT;
window.ANIM_WINDOW_PREV = window.ANIM_WINDOW_PREV_DEFAULT;
window.ANIM_WINDOW_AFTER = window.ANIM_WINDOW_AFTER_DEFAULT;
}
} else {
window.ANIM_DISPLAY_MODE = window.ANIM_DISPLAY_MODE_DEFAULT;
window.ANIM_GRANULARITY = window.ANIM_GRANULARITY_DEFAULT;
var _initPreset = window.ANIM_PRESETS[window.ANIM_DISPLAY_MODE_DEFAULT];
window.ANIM_OPACITY_PREV = _initPreset ? _initPreset.prev_opacity : window.ANIM_OPACITY_PREV_DEFAULT;
window.ANIM_OPACITY_AFTER = _initPreset ? _initPreset.after_opacity : window.ANIM_OPACITY_AFTER_DEFAULT;
window.ANIM_WINDOW_PREV = _initPreset ? _initPreset.prev_words : window.ANIM_WINDOW_PREV_DEFAULT;
window.ANIM_WINDOW_AFTER = _initPreset ? _initPreset.after_words : window.ANIM_WINDOW_AFTER_DEFAULT;
window.ANIM_VERSE_MODE = false;
}
window.ANIM_PLAYBACK_RATE = (_saved && _saved.playbackRate) ? _saved.playbackRate : 1;
// --- Accordion open/close persistence (main view only; mega handled by animate-all.js) ---
window._animateAllActive = false;
if (_saved && _saved.accordionOpenMain === true) {
requestAnimationFrame(function() {
var acc = document.getElementById('anim-settings-accordion');
if (acc && !acc.classList.contains('open')) {
var btn = acc.querySelector(':scope > .label-wrap');
if (btn) btn.click();
}
});
}
(function() {
var acc = document.getElementById('anim-settings-accordion');
if (!acc) return;
var btn = acc.querySelector(':scope > .label-wrap');
if (btn) btn.addEventListener('click', function() {
setTimeout(saveAnimSettings, 50);
});
})();
window._windowPrevGradient = [];
window._windowAfterGradient = [];
function rebuildWindowGradient() {
var basePrev = window.ANIM_OPACITY_PREV;
var baseAfter = window.ANIM_OPACITY_AFTER;
var pc = window.ANIM_WINDOW_PREV;
var ac = window.ANIM_WINDOW_AFTER;
window._windowPrevGradient = [];
window._windowAfterGradient = [];
// At max: empty gradient signals "show all at flat base opacity"
if (pc < window.ANIM_WINDOW_PREV_MAX) {
for (var d = 1; d <= pc; d++) {
window._windowPrevGradient.push(String(basePrev * (pc - d + 1) / pc));
}
}
if (ac < window.ANIM_WINDOW_AFTER_MAX) {
for (var d = 1; d <= ac; d++) {
window._windowAfterGradient.push(String(baseAfter * (ac - d + 1) / ac));
}
}
}
rebuildWindowGradient();
// Activate a lazy-loaded audio element (set src, show controls, hide play button)
function activateAudio(audio) {
if (audio.hasAttribute('controls')) return audio;
audio.src = audio.dataset.src;
audio.controls = true;
audio.style.display = '';
var playBtn = audio.parentElement && audio.parentElement.querySelector('.play-btn');
if (playBtn) playBtn.style.display = 'none';
return audio;
}
// =====================================================================
// Animation Debug Logging
// Enable via: window.ANIM_DEBUG = true; in browser console
// =====================================================================
window.ANIM_DEBUG = false; // Set to true to enable animation debug logging
function animDebug(category, msg, data) {
if (!window.ANIM_DEBUG) return;
var prefix = '[ANIM:' + category + ']';
if (data !== undefined) {
console.log(prefix, msg, data);
} else {
console.log(prefix, msg);
}
}
function dumpCacheTimestamps(cache, label) {
if (!window.ANIM_DEBUG) return;
console.group('[ANIM:CACHE] ' + label + ' (' + cache.length + ' entries)');
cache.forEach(function(item, idx) {
var text = item.el.textContent.substring(0, 20);
var gid = item.groupId || '-';
console.log(idx + ': "' + text + '" start=' + item.start.toFixed(3) + ' end=' + item.end.toFixed(3) + ' groupId=' + gid);
});
if (cache._groupIndex) {
console.log('_groupIndex:', JSON.parse(JSON.stringify(cache._groupIndex)));
}
console.groupEnd();
}
// Cache elements and timing data for a given selector
// Also builds group index for letter groups (chars with same data-group-id)
function initCacheFor(card, selector) {
var elements = Array.from(card.querySelectorAll(selector));
var cache = elements.map(function(el, idx) {
return {
el: el,
start: parseFloat(el.dataset.start),
end: parseFloat(el.dataset.end),
groupId: el.dataset.groupId || null,
cacheIdx: idx
};
});
// Build group index: groupId → [cacheIdx, ...]
var groupIndex = {};
cache.forEach(function(item) {
if (item.groupId) {
if (!groupIndex[item.groupId]) {
groupIndex[item.groupId] = [];
}
groupIndex[item.groupId].push(item.cacheIdx);
}
});
cache._groupIndex = groupIndex;
animDebug('INIT', 'initCacheFor("' + selector + '"): ' + cache.length + ' elements, ' + Object.keys(groupIndex).length + ' groups');
return cache;
}
// Apply class to element and all members of its letter group
function applyClassToGroup(cache, idx, className, add) {
var item = cache[idx];
if (!item) {
animDebug('CLASS', 'applyClassToGroup SKIP: no item at idx=' + idx);
return;
}
var text = item.el.textContent.substring(0, 10);
animDebug('CLASS', (add ? '+' : '-') + className + ' idx=' + idx + ' "' + text + '" groupId=' + (item.groupId || '-'));
if (add) {
item.el.classList.add(className);
} else {
item.el.classList.remove(className);
}
// Also apply to all group members if this element has a groupId
if (item.groupId && cache._groupIndex) {
var groupMembers = cache._groupIndex[item.groupId] || [];
if (groupMembers.length > 1) {
animDebug('CLASS', ' -> propagating to group members:', groupMembers);
}
groupMembers.forEach(function(memberIdx) {
if (memberIdx !== idx) {
if (add) {
cache[memberIdx].el.classList.add(className);
} else {
cache[memberIdx].el.classList.remove(className);
}
}
});
}
}
// Return the active cache based on current granularity setting
function getActiveCache(audio) {
return window.ANIM_GRANULARITY === 'Characters' ? audio._cacheChars : audio._cacheWords;
}
// Apply opacity to element and all members of its letter group
function applyOpacityToGroup(cache, idx, opacity) {
var item = cache[idx];
if (!item) {
animDebug('OPACITY', 'applyOpacityToGroup SKIP: no item at idx=' + idx);
return;
}
var text = item.el.textContent.substring(0, 10);
animDebug('OPACITY', 'idx=' + idx + ' "' + text + '" opacity=' + (opacity === null ? 'CLEAR' : opacity) + ' groupId=' + (item.groupId || '-'));
if (opacity === null) {
item.el.style.removeProperty('opacity');
} else {
item.el.style.opacity = opacity;
}
// Also apply to all group members if this element has a groupId
if (item.groupId && cache._groupIndex) {
var groupMembers = cache._groupIndex[item.groupId] || [];
if (groupMembers.length > 1) {
animDebug('OPACITY', ' -> propagating to group members:', groupMembers);
}
groupMembers.forEach(function(memberIdx) {
if (memberIdx !== idx) {
if (opacity === null) {
cache[memberIdx].el.style.removeProperty('opacity');
} else {
cache[memberIdx].el.style.opacity = opacity;
}
}
});
}
}
// Build group index for a cache array (extracts groupId and builds _groupIndex)
// Used for megacard caches which are built manually without initCacheFor()
function buildGroupIndex(cache) {
var groupIndex = {};
cache.forEach(function(item, idx) {
var gid = item.el.dataset.groupId;
if (gid) {
item.groupId = gid;
if (!groupIndex[gid]) groupIndex[gid] = [];
groupIndex[gid].push(idx);
}
});
cache._groupIndex = groupIndex;
var groupCount = Object.keys(groupIndex).length;
animDebug('GROUP', 'buildGroupIndex: ' + cache.length + ' elements, ' + groupCount + ' groups');
if (window.ANIM_DEBUG && groupCount > 0) {
for (var gid in groupIndex) {
if (groupIndex[gid].length > 1) {
animDebug('GROUP', ' group "' + gid + '": indices ' + JSON.stringify(groupIndex[gid]));
}
}
}
}
// Build verse index lazily for verse-mode delta updates.
// Stores _verseOf[i] = "surah:ayah" and _verseRanges = { "surah:ayah": {start, end} }
function ensureVerseIndex(cache) {
if (cache._verseOf) return;
var verseOf = new Array(cache.length);
var verseRanges = {};
for (var i = 0; i < cache.length; i++) {
var el = cache[i].el;
var pos = el.dataset.pos;
if (!pos) {
var wordEl = el.closest('.word');
if (wordEl) pos = wordEl.dataset.pos;
}
var verse = '';
if (pos) {
var parts = pos.split(':');
if (parts.length >= 2) verse = parts[0] + ':' + parts[1];
}
verseOf[i] = verse;
if (verse) {
if (!verseRanges[verse]) {
verseRanges[verse] = { start: i, end: i };
} else {
verseRanges[verse].end = i;
}
}
}
cache._verseOf = verseOf;
cache._verseRanges = verseRanges;
animDebug('VERSE', 'ensureVerseIndex: ' + cache.length + ' elements, ' + Object.keys(verseRanges).length + ' verses');
}
// Window mode: track active state for live slider updates
window._windowActiveCache = null;
window._windowActiveIdx = -1;
window._windowLastPc = 0;
window._windowLastAc = 0;
window._windowLastPcAll = false;
window._windowLastAcAll = false;
window._windowSettingsVersion = 0; // Incremented when sliders change, so tick() can detect
window._windowLastVerse = null; // Last active verse in verse mode (null = not active or first call)
// Window mode: apply per-element opacity gradient around active index
function applyWindowOpacity(cache, newIdx, prevIdx) {
animDebug('WINDOW', 'applyWindowOpacity newIdx=' + newIdx + ' prevIdx=' + prevIdx + ' cacheLen=' + cache.length);
// If prevIdx is unknown (e.g. after a timing gap between words),
// fall back to the last index we applied so the old window gets cleared.
if (prevIdx < 0 && window._windowActiveIdx >= 0 && window._windowActiveCache === cache) {
animDebug('WINDOW', ' -> using fallback prevIdx=' + window._windowActiveIdx);
prevIdx = window._windowActiveIdx;
}
var prevGrad = window._windowPrevGradient;
var afterGrad = window._windowAfterGradient;
var pc = prevGrad.length;
var ac = afterGrad.length;
var pcAll = (window.ANIM_WINDOW_PREV >= window.ANIM_WINDOW_PREV_MAX);
var acAll = (window.ANIM_WINDOW_AFTER >= window.ANIM_WINDOW_AFTER_MAX);
var oldPcAll = window._windowLastPcAll;
var oldAcAll = window._windowLastAcAll;
var basePrev = String(window.ANIM_OPACITY_PREV);
var baseAfter = String(window.ANIM_OPACITY_AFTER);
animDebug('WINDOW', ' verseMode=' + window.ANIM_VERSE_MODE + ' pcAll=' + pcAll + ' acAll=' + acAll + ' basePrev=' + basePrev + ' baseAfter=' + baseAfter);
// Verse mode: show only current-verse words, hide everything else.
// Uses pre-built verse index for O(verse_size) updates instead of O(n) full scan.
if (window.ANIM_VERSE_MODE) {
ensureVerseIndex(cache);
var activeVerse = cache._verseOf[newIdx] || '';
var lastVerse = window._windowLastVerse;
var verseRange = activeVerse ? cache._verseRanges[activeVerse] : null;
// Helper: apply opacity to all elements in a verse range
function showVerseRange(range, activeIdx) {
if (!range) return;
for (var i = range.start; i <= range.end; i++) {
if (i === activeIdx) {
applyOpacityToGroup(cache, i, null);
} else {
applyOpacityToGroup(cache, i, (i < activeIdx) ? basePrev : baseAfter);
}
}
}
if (lastVerse === null) {
// First verse-mode call (after toggle or fresh start): hide everything, show active verse
for (var i = 0; i < cache.length; i++) {
cache[i].el.style.opacity = '0';
}
showVerseRange(verseRange, newIdx);
} else if (activeVerse === lastVerse && prevIdx >= 0 && prevIdx !== newIdx) {
// Same verse, advancing — delta: just swap active element (2 writes)
applyOpacityToGroup(cache, prevIdx, basePrev);
applyOpacityToGroup(cache, newIdx, null);
} else if (activeVerse === lastVerse) {
// Same verse, reapply (settings change or same position) — refresh entire verse
showVerseRange(verseRange, newIdx);
} else {
// Verse transition — hide old verse, show new verse
var oldRange = lastVerse ? cache._verseRanges[lastVerse] : null;
if (oldRange) {
for (var i = oldRange.start; i <= oldRange.end; i++) {
applyOpacityToGroup(cache, i, '0');
}
}
showVerseRange(verseRange, newIdx);
}
window._windowLastVerse = activeVerse;
window._windowActiveCache = cache;
window._windowActiveIdx = newIdx;
return;
}
// Not in verse mode — reset verse tracking so next activation does a fresh setup
window._windowLastVerse = null;
// Fast path: All→All steady state — only 2 elements change (with group support)
if (prevIdx >= 0 && newIdx >= 0 && pcAll && acAll && oldPcAll && oldAcAll) {
applyOpacityToGroup(cache, prevIdx, basePrev);
applyOpacityToGroup(cache, newIdx, null);
window._windowActiveCache = cache;
window._windowActiveIdx = newIdx;
window._windowLastPc = pc; window._windowLastAc = ac;
window._windowLastPcAll = pcAll; window._windowLastAcAll = acAll;
return;
}
// Fast path: Half-all steady state (pcAll XOR acAll), advancing by exactly 1.
// Only update the delta: 2 elements + bounded gradient shift (max 15).
if (prevIdx >= 0 && newIdx === prevIdx + 1 &&
(pcAll || acAll) && !(pcAll && acAll) &&
pcAll === oldPcAll && acAll === oldAcAll &&
pc === window._windowLastPc && ac === window._windowLastAc) {
if (pcAll) {
// Reveal-like: all previous visible, small/no after window.
// prevIdx joins the "all prev" pool.
applyOpacityToGroup(cache, prevIdx, basePrev);
// Shift after gradient (if any) — covers new window [newIdx+1..newIdx+ac]
for (var a = 0; a < ac; a++) {
var idx = newIdx + (a + 1);
if (idx >= cache.length) break;
applyOpacityToGroup(cache, idx, afterGrad[a]);
}
// Active word last — null wins if group overlaps with gradient
applyOpacityToGroup(cache, newIdx, null);
} else {
// Consume-like: small/no prev window, all after visible.
if (pc > 0) {
// Shift prev gradient — covers new window [newIdx-pc..newIdx-1]
for (var p = 0; p < pc; p++) {
var idx = newIdx - (p + 1);
if (idx < 0) break;
applyOpacityToGroup(cache, idx, prevGrad[p]);
}
// Hide element that fell out of prev window
var hideIdx = newIdx - pc - 1;
if (hideIdx >= 0) {
applyOpacityToGroup(cache, hideIdx, null);
}
} else {
// No prev window (pure Consume): hide old active (CSS opacity:0)
applyOpacityToGroup(cache, prevIdx, null);
}
// Active word last
applyOpacityToGroup(cache, newIdx, null);
}
window._windowActiveCache = cache;
window._windowActiveIdx = newIdx;
window._windowLastPc = pc; window._windowLastAc = ac;
window._windowLastPcAll = pcAll; window._windowLastAcAll = acAll;
return;
}
// Clear old window range
if (prevIdx >= 0) {
if (oldPcAll || oldAcAll) {
// Previous state had "all" — clear every element
for (var i = 0; i < cache.length; i++) {
cache[i].el.style.removeProperty('opacity');
}
} else {
var clearPc = Math.max(pc, window._windowLastPc);
var clearAc = Math.max(ac, window._windowLastAc);
var clearStart = Math.max(0, prevIdx - clearPc);
var clearEnd = Math.min(cache.length - 1, prevIdx + clearAc);
for (var i = clearStart; i <= clearEnd; i++) {
cache[i].el.style.removeProperty('opacity');
}
}
}
// Track state for live slider updates
window._windowActiveCache = cache;
window._windowActiveIdx = newIdx;
window._windowLastPc = pc;
window._windowLastAc = ac;
window._windowLastPcAll = pcAll;
window._windowLastAcAll = acAll;
if (newIdx < 0) return;
// Apply previous elements (with group support)
if (pcAll) {
for (var i = 0; i < newIdx; i++) {
applyOpacityToGroup(cache, i, basePrev);
}
} else {
for (var p = 0; p < pc; p++) {
var idx = newIdx - (p + 1);
if (idx < 0) break;
applyOpacityToGroup(cache, idx, prevGrad[p]);
}
}
// Apply after elements (upcoming words always get opacity set for
// proper word-by-word animation, even if they have 'reached' from
// a previous segment) — with group support
if (acAll) {
for (var i = newIdx + 1; i < cache.length; i++) {
applyOpacityToGroup(cache, i, baseAfter);
}
} else {
for (var a = 0; a < ac; a++) {
var idx = newIdx + (a + 1);
if (idx >= cache.length) break;
applyOpacityToGroup(cache, idx, afterGrad[a]);
}
}
// Fade previously-active word from full opacity to its new level (with group support)
if (prevIdx >= 0 && prevIdx !== newIdx) {
var tgt = cache[prevIdx].el.style.opacity || '0';
applyOpacityToGroup(cache, prevIdx, tgt);
}
// Reconcile group opacities: grouped characters should appear as
// one visual unit, using the max opacity from any member
if (cache._groupIndex) {
var gids = Object.keys(cache._groupIndex);
for (var g = 0; g < gids.length; g++) {
var members = cache._groupIndex[gids[g]];
if (members.length <= 1) continue;
// If any member is active, set all to full opacity
var anyActive = false;
var maxOp = -1;
for (var m = 0; m < members.length; m++) {
if (cache[members[m]].el.classList.contains('active')) {
anyActive = true;
break;
}
var op = cache[members[m]].el.style.opacity;
if (op !== '') {
var val = parseFloat(op);
if (!isNaN(val) && val > maxOp) maxOp = val;
}
}
if (anyActive) {
for (var m = 0; m < members.length; m++) {
cache[members[m]].el.style.opacity = '1';
}
} else if (maxOp > 0) {
var maxOpStr = String(maxOp);
for (var m = 0; m < members.length; m++) {
cache[members[m]].el.style.opacity = maxOpStr;
}
}
// maxOp <= 0: group is hidden (outside window), leave as-is
}
}
}
// Re-apply window opacity immediately (called when sliders change mid-animation)
function reapplyWindowNow() {
var cache = window._windowActiveCache;
var idx = window._windowActiveIdx;
if (!cache || idx < 0) return;
applyWindowOpacity(cache, idx, idx);
}
// Replace numeric value with "All" when slider is at maximum
function updateWindowMaxLabel(elemId, val, maxVal) {
var el = document.getElementById(elemId);
if (!el) return;
var numInput = el.querySelector('input[type="number"]');
if (!numInput) return;
if (val >= maxVal) {
numInput.style.display = 'none';
var maxSpan = el.querySelector('.max-label');
if (!maxSpan) {
maxSpan = document.createElement('span');
maxSpan.className = 'max-label';
maxSpan.style.cssText = 'font-weight: bold; opacity: 0.85;';
numInput.parentNode.insertBefore(maxSpan, numInput.nextSibling);
}
maxSpan.textContent = 'All';
maxSpan.style.display = '';
} else {
numInput.style.display = '';
var maxSpan = el.querySelector('.max-label');
if (maxSpan) maxSpan.style.display = 'none';
}
// Always inject a hint at the right end of the slider track
if (!el.querySelector('.max-hint')) {
var rangeWrap = el.querySelector('input[type="range"]');
if (rangeWrap) {
var hint = document.createElement('span');
hint.className = 'max-hint';
hint.textContent = 'All';
hint.style.cssText = 'position: absolute; right: 0; bottom: -1.2em; font-size: 0.7em; opacity: 0.5;';
var parent = rangeWrap.parentNode;
if (parent) {
parent.style.position = 'relative';
parent.appendChild(hint);
}
}
}
}
// Expose to global scope so Gradio inline js= callbacks can call them
window.rebuildWindowGradient = rebuildWindowGradient;
window.reapplyWindowNow = reapplyWindowNow;
window.updateWindowMaxLabel = updateWindowMaxLabel;
window.saveAnimSettings = saveAnimSettings;
window.loadAnimSettings = loadAnimSettings;
// Inject "All" hints on slider tracks once Gradio renders them
setTimeout(function() {
updateWindowMaxLabel('anim-window-prev', window.ANIM_WINDOW_PREV, window.ANIM_WINDOW_PREV_MAX);
updateWindowMaxLabel('anim-window-after', window.ANIM_WINDOW_AFTER, window.ANIM_WINDOW_AFTER_MAX);
}, 500);
// Clear inline opacity from all words/chars in a card (Window mode cleanup)
// Applies mode's prev_opacity instead of removing opacity entirely
function clearWindowOpacity(card) {
var prevOp = window.ANIM_OPACITY_PREV;
card.querySelectorAll('.word, .char').forEach(function(el) {
// Apply mode's prev_opacity consistently:
// - Reveal/Fade (1.0): full visibility
// - Spotlight (0.3): dimmed
// - Isolate/Consume (0): hidden/disappear
if (prevOp >= 1) {
el.style.removeProperty('opacity');
} else {
el.style.opacity = String(prevOp);
}
});
}
function clearHighlights(card) {
card.querySelectorAll('.word.active, .word.reached, .char.active, .char.reached').forEach(function(w) {
w.classList.remove('active', 'reached');
});
clearWindowOpacity(card);
card.classList.remove('anim-window', 'anim-chars');
}
function stopAnimation(audio, card) {
if (audio._rafId) {
cancelAnimationFrame(audio._rafId);
audio._rafId = null;
}
if (card) {
// Apply mode's prev_opacity to last active word before clearing
var activeEl = card.querySelector('.word.active, .char.active');
if (activeEl && window.ANIM_OPACITY_PREV < 1) {
activeEl.style.opacity = String(window.ANIM_OPACITY_PREV);
}
clearHighlights(card);
}
}
function startAnimation(audio, card) {
var lastWordIdx = -1;
var lastGranularity = window.ANIM_GRANULARITY;
var lastOpacityPrev = window.ANIM_OPACITY_PREV;
var lastSeenVersion = window._windowSettingsVersion;
// Segment audio is trimmed, so currentTime starts at 0.
// Word timestamps are absolute, so we need to add segment offset.
var segOffset = parseFloat(card.dataset.startTime) || 0;
function tick() {
if (audio.paused || audio.ended) return;
var wordCache = getActiveCache(audio);
var currentTime = audio.currentTime + segOffset;
// Granularity switched mid-animation — clear old highlights and reset
if (window.ANIM_GRANULARITY !== lastGranularity) {
animDebug('TICK', 'Granularity changed: ' + lastGranularity + ' -> ' + window.ANIM_GRANULARITY);
card.querySelectorAll('.word.active, .word.reached, .char.active, .char.reached').forEach(function(w) {
w.classList.remove('active', 'reached');
});
clearWindowOpacity(card);
lastWordIdx = -1;
lastGranularity = window.ANIM_GRANULARITY;
}
// Mode changed mid-animation — refresh all reached words with new opacity
if (window.ANIM_OPACITY_PREV !== lastOpacityPrev) {
animDebug('TICK', 'Mode changed: prevOp ' + lastOpacityPrev + ' -> ' + window.ANIM_OPACITY_PREV);
card.querySelectorAll('.word.reached, .char.reached').forEach(function(el) {
if (window.ANIM_OPACITY_PREV >= 1) {
el.style.removeProperty('opacity');
} else {
el.style.opacity = String(window.ANIM_OPACITY_PREV);
}
});
lastOpacityPrev = window.ANIM_OPACITY_PREV;
}
// Slider settings changed mid-animation — reapply window opacity
if (window._windowSettingsVersion !== lastSeenVersion) {
if (lastWordIdx >= 0) {
applyWindowOpacity(wordCache, lastWordIdx, lastWordIdx);
}
lastSeenVersion = window._windowSettingsVersion;
}
var newWordIdx = -1;
var searchPath = '';
// Fast path: check current word, then next (covers ~99% of frames)
if (lastWordIdx >= 0 && lastWordIdx < wordCache.length &&
currentTime >= wordCache[lastWordIdx].start && currentTime < wordCache[lastWordIdx].end) {
newWordIdx = lastWordIdx;
searchPath = 'same';
} else if (lastWordIdx + 1 < wordCache.length &&
currentTime >= wordCache[lastWordIdx + 1].start && currentTime < wordCache[lastWordIdx + 1].end) {
newWordIdx = lastWordIdx + 1;
searchPath = 'next';
} else {
// Fallback: full scan (seeking, granularity switch, etc.)
searchPath = 'scan';
for (var i = 0; i < wordCache.length; i++) {
if (currentTime >= wordCache[i].start && currentTime < wordCache[i].end) {
newWordIdx = i;
break;
}
}
// Clamp to last word when past its end but audio hasn't ended yet
if (newWordIdx === -1 && wordCache.length > 0 && currentTime >= wordCache[wordCache.length - 1].start) {
newWordIdx = wordCache.length - 1;
searchPath = 'clamp';
}
}
// Only update DOM if word changed
if (newWordIdx !== lastWordIdx) {
var newText = newWordIdx >= 0 ? wordCache[newWordIdx].el.textContent.substring(0, 15) : '-';
animDebug('TICK', 'idx change: ' + lastWordIdx + ' -> ' + newWordIdx + ' (' + searchPath + ') t=' + currentTime.toFixed(3) + ' "' + newText + '"');
if (newWordIdx === -1 && wordCache.length > 0) {
// No match - log surrounding timing info
var first = wordCache[0];
var last = wordCache[wordCache.length - 1];
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) + ']');
}
if (lastWordIdx >= 0 && lastWordIdx < wordCache.length) {
applyClassToGroup(wordCache, lastWordIdx, 'active', false);
applyClassToGroup(wordCache, lastWordIdx, 'reached', true);
}
if (newWordIdx >= 0) {
applyClassToGroup(wordCache, newWordIdx, 'active', true);
if (lastWordIdx === -1) {
// First highlight — catch up any skipped words (with group support)
for (var j = 0; j < newWordIdx; j++) {
applyClassToGroup(wordCache, j, 'reached', true);
}
}
}
if (newWordIdx >= 0) {
applyWindowOpacity(wordCache, newWordIdx, lastWordIdx);
}
lastWordIdx = newWordIdx;
}
}
function rafLoop() {
tick();
if (!audio.paused && !audio.ended) {
audio._rafId = requestAnimationFrame(rafLoop);
}
}
audio._rafId = requestAnimationFrame(rafLoop);
}
function toggleAnimation(btn) {
var card = btn.closest('.segment-card');
if (!card) return;
var audio = card.querySelector('audio');
if (!audio) return;
var isActive = btn.classList.toggle('active');
if (isActive) {
btn.textContent = 'Stop';
// Apply window engine class to card
card.classList.add('anim-window');
if (window.ANIM_GRANULARITY === 'Characters') {
card.classList.add('anim-chars');
}
// Build both caches upfront for live granularity switching
audio._cacheWords = initCacheFor(card, '.segment-text .word');
audio._cacheChars = initCacheFor(card, '.segment-text .char');
animDebug('START', 'toggleAnimation: words=' + audio._cacheWords.length + ' chars=' + audio._cacheChars.length + ' granularity=' + window.ANIM_GRANULARITY);
dumpCacheTimestamps(audio._cacheWords, 'Words');
dumpCacheTimestamps(audio._cacheChars, 'Chars');
activateAudio(audio);
startAnimation(audio, card);
audio.play();
} else {
btn.textContent = 'Animate';
audio.pause();
stopAnimation(audio, card);
}
}