/* * TalkThrough — narration for a user manual article. * * The server already does the hard part: `?mode=speech` returns the MP3 * (generating and caching it in S3 on first request) and `?mode=timings` * returns `{duration, lang, cues:[{term,time}], words:[{w,t}]}` aligned to * that exact audio. This just drives them from the page. * * Nothing is requested until the reader presses play. The first request for * an article can take a while — the audio is being narrated — so the button * goes into a loading state rather than looking broken. */ (function () { 'use strict'; var root = document.getElementById('talkthrough'); if (!root) { return; } var audio = document.getElementById('talkthrough-audio'); var toggle = document.getElementById('talkthrough-toggle'); var label = document.getElementById('talkthrough-label'); var seek = document.getElementById('talkthrough-seek'); var timeEl = document.getElementById('talkthrough-time'); var speedBtn = document.getElementById('talkthrough-speed'); if (!audio || !toggle) { return; } // Reveal only once we know the browser can run this and play the format. if (!audio.canPlayType || !audio.canPlayType('audio/mpeg')) { return; } root.hidden = false; // The dock is sticky; park it just below the site's own sticky nav // rather than guessing a fixed offset, since the nav's height changes // with the breakpoint and when its links wrap. var dock = document.getElementById('talkthrough-dock'); function measureStickyTop() { if (!dock) { return; } var nav = document.querySelector('.nav'); var navBottom = 0; if (nav && getComputedStyle(nav).position === 'sticky') { navBottom = nav.getBoundingClientRect().height; } dock.style.setProperty('--tt-stick-top', (navBottom + 8) + 'px'); } measureStickyTop(); window.addEventListener('resize', measureStickyTop); var LISTEN = label ? label.textContent : 'Listen to this article'; var ERROR = root.getAttribute('data-error') || 'Narration is unavailable for this article right now.'; var SPEEDS = [1, 1.25, 1.5, 0.75]; var speedIdx = 0; var loaded = false; var seeking = false; var timings = null; // Build sibling URLs from the current article, preserving ?lang= so the // narration matches the language actually on screen. function modeURL(mode, extra) { var lang = new URLSearchParams(window.location.search).get('lang'); var qs = '?mode=' + encodeURIComponent(mode); if (lang) { qs += '&lang=' + encodeURIComponent(lang); } if (extra) { qs += '&' + extra; } return window.location.pathname + qs; } function clock(secs) { if (!isFinite(secs) || secs < 0) { secs = 0; } var m = Math.floor(secs / 60), s = Math.floor(secs % 60); return m + ':' + (s < 10 ? '0' : '') + s; } function setState(name) { root.classList.remove('is-loading', 'is-playing'); if (name) { root.classList.add(name); } } // Opening the transport is deliberately tied to the press, not to // playback starting: on a cold article the audio is still being // narrated, and waiting until then would leave the press feeling dead. function expand() { root.classList.add('is-expanded'); } function collapse() { root.classList.remove('is-expanded'); } function fail(message) { setState(null); collapse(); toggle.disabled = false; if (label) { label.textContent = LISTEN; } var err = document.getElementById('talkthrough-error'); if (!err) { err = document.createElement('p'); err.id = 'talkthrough-error'; (root.parentNode || root).appendChild(err); } err.textContent = message; } function clearError() { var err = document.getElementById('talkthrough-error'); if (err && err.parentNode) { err.parentNode.removeChild(err); } } // ---- playback ------------------------------------------------------- toggle.addEventListener('click', function () { clearError(); if (!loaded) { loaded = true; setState('is-loading'); expand(); toggle.disabled = true; audio.src = modeURL('speech', 'disposition=inline'); audio.play().catch(function () { loaded = false; fail(ERROR); }); return; } if (audio.paused) { audio.play().catch(function () {}); } else { audio.pause(); } }); audio.addEventListener('playing', function () { setState('is-playing'); expand(); toggle.disabled = false; loadTimings(); }); audio.addEventListener('pause', function () { setState(null); }); audio.addEventListener('ended', function () { setState(null); if (seek) { seek.value = 0; } highlight(null); }); audio.addEventListener('error', function () { loaded = false; fail(ERROR); }); audio.addEventListener('timeupdate', function () { var d = audio.duration; if (!seeking && seek && isFinite(d) && d > 0) { seek.value = Math.round((audio.currentTime / d) * 1000); } if (timeEl) { timeEl.textContent = clock(audio.currentTime) + (isFinite(d) && d > 0 ? ' / ' + clock(d) : ''); } syncHighlight(); }); if (seek) { seek.addEventListener('input', function () { seeking = true; }); seek.addEventListener('change', function () { var d = audio.duration; if (isFinite(d) && d > 0) { audio.currentTime = (seek.value / 1000) * d; } seeking = false; }); } if (speedBtn) { speedBtn.addEventListener('click', function () { speedIdx = (speedIdx + 1) % SPEEDS.length; audio.playbackRate = SPEEDS[speedIdx]; speedBtn.innerHTML = SPEEDS[speedIdx] + '×'; }); } // ---- highlighting --------------------------------------------------- // Best effort. The cue list is headings, bold terms and link text in // document order, so each one is matched forward from the last hit // rather than from the top — that keeps a word repeated across the // article from dragging the highlight backwards. Any failure here must // never interrupt playback, so it all runs inside a try. function loadTimings() { if (timings !== null) { return; } timings = []; fetch(modeURL('timings'), { credentials: 'same-origin' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (data && data.cues && data.cues.length) { timings = data.cues; } }) .catch(function () { /* highlighting is optional */ }); } var marked = null; var cueIdx = -1; var searchFrom = 0; // Scrolling the reader along with the narration is helpful right up // until they want to read ahead, so any manual scroll hands control // back for a while. Their own scrolling must not itself count as // interference, hence the flag around the programmatic scroll. var FOLLOW_PAUSE_MS = 12000; var followPausedUntil = 0; var selfScrolling = 0; window.addEventListener('scroll', function () { if (Date.now() < selfScrolling) { return; } followPausedUntil = Date.now() + FOLLOW_PAUSE_MS; }, { passive: true }); function follow(el) { if (!el || Date.now() < followPausedUntil) { return; } var rect = el.getBoundingClientRect(); if (!rect || (rect.top === 0 && rect.bottom === 0)) { return; } // Keep the highlight inside a comfortable band: below the sticky // dock, above the bottom edge. Only move when it leaves that band. var dockRect = dock ? dock.getBoundingClientRect() : { bottom: 0 }; var top = Math.max(dockRect.bottom, 0) + 24; var bottom = window.innerHeight - 80; if (rect.top >= top && rect.bottom <= bottom) { return; } var target = Math.max(0, window.pageYOffset + rect.top - (top + 40)); var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; scrollToY(target, reduce); } // Tweened by hand rather than via scrollTo({behavior:'smooth'}). That // option is a silent no-op in some engines and whenever the user has // reduced motion enforced at the OS level, which would leave the reader // stranded with the narration off screen and no indication why. A rAF // tween behaves the same everywhere, and each frame refreshes the // self-scroll guard so our own movement is not mistaken for the // reader taking over. var followAnim = null; function scrollToY(y, instant) { if (followAnim) { cancelAnimationFrame(followAnim); followAnim = null; } var startY = window.pageYOffset; var delta = y - startY; selfScrolling = Date.now() + 600; if (instant || Math.abs(delta) < 2) { window.scrollTo(0, y); return; } var duration = Math.min(700, 220 + Math.abs(delta) * 0.35); var t0 = null; var step = function (ts) { if (t0 === null) { t0 = ts; } var p = Math.min(1, (ts - t0) / duration); var eased = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; window.scrollTo(0, Math.round(startY + delta * eased)); selfScrolling = Date.now() + 600; followAnim = p < 1 ? requestAnimationFrame(step) : null; }; followAnim = requestAnimationFrame(step); } function highlight(range) { if (marked && marked.parentNode) { var text = document.createTextNode(marked.textContent); marked.parentNode.replaceChild(text, marked); text.parentNode.normalize(); } marked = null; if (!range) { return; } try { var span = document.createElement('span'); span.className = 'talkthrough-mark'; range.surroundContents(span); marked = span; } catch (e) { /* term spans element boundaries — skip it */ } } // Walk the article's text nodes and return a Range around `term`, // starting the search at global offset `from`. function findTerm(term, from) { var article = document.getElementById('instructions'); if (!article || !term) { return null; } var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, null, false); var needle = term.toLowerCase(); var offset = 0, node; while ((node = walker.nextNode())) { var text = node.nodeValue; var len = text.length; if (offset + len > from) { var startAt = Math.max(0, from - offset); var hit = text.toLowerCase().indexOf(needle, startAt); if (hit !== -1) { var range = document.createRange(); range.setStart(node, hit); range.setEnd(node, hit + term.length); return { range: range, end: offset + hit + term.length }; } } offset += len; } return null; } function syncHighlight() { if (!timings || !timings.length) { return; } try { var t = audio.currentTime, idx = -1; for (var i = 0; i < timings.length; i++) { if (timings[i].time <= t) { idx = i; } else { break; } } if (idx === cueIdx) { return; } // Jumping backwards (a seek) invalidates the forward cursor. if (idx < cueIdx) { searchFrom = 0; } cueIdx = idx; if (idx < 0) { highlight(null); return; } var found = findTerm(timings[idx].term, searchFrom); if (!found) { found = findTerm(timings[idx].term, 0); } if (found) { highlight(found.range); follow(marked); searchFrom = found.end; } else { highlight(null); } } catch (e) { /* never let highlighting break playback */ } } })();