/* * 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; 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; 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); searchFrom = found.end; } else { highlight(null); } } catch (e) { /* never let highlighting break playback */ } } })();