Sunday, September 21, 2025

How You Can Add a "Read Aloud" Button for a Section of Text, Like the Pros

Back to list

In this post you’ll wire up a small script that turns a simple button into a section reader using the browser’s SpeechSynthesis (Text‑to‑Speech). It supports Pause/Resume, a mobile‑safe Stop, and natural pauses between list items. All snippets include a Copy code button.

What you’ll build
  • Per‑section button: <button class="ra-btn" data-target="...">Read Aloud</button>
  • Script that you paste once in your theme (just before </body>)
  • Natural breaks for <ul>/<ol> items without adding periods
Read more

Step 1 — Add a button and a target to your post

Put your button anywhere, and point it at the element you want read:

<button class="ra-btn" data-target="bangkok_rules">Read Aloud</button>

<ol id="bangkok_rules">
  <li>Carry a notebook everywhere—brilliance is a shy guest</li>
  <li>Learn the names of constellations, not just the apps on your phone</li>
  <li>Eat something you can’t pronounce at least once a year</li>
  <li>Always know how to make one dish that impresses strangers</li>
  <li>Ask elders about the wildest story they’ve never told anyone</li>
  <li>Watch cities wake up at dawn; they keep secrets at sunrise</li>
  <li>Travel with two books: one you understand, one you don’t</li>
  <li>Make eye contact with statues—they’ve been waiting centuries</li>
  <li>Learn to whistle with your fingers; it’s a survival skill</li>
  <li>Never trust a person who refuses dessert</li>
</ol>

Tip: You can have multiple buttons/targets per page—each button just needs its own data-target pointing to an element id.

Step 2 — Paste the script into your theme (once)

Go to Theme → Edit HTML and paste the following just before </body>. The CDATA wrapper makes it safe for Blogger’s XML parser.

<script type='text/javascript'>
//<![CDATA[
(() => {
  const LABELS = {
    read:   "\uD83D\uDD0A Read Aloud",  // 🔊
    pause:  "\u23F8\uFE0F Pause",       // ⏸️
    resume: "\u25B6\uFE0F Resume",      // ▶️
    stop:   "\u23F9\uFE0F Stop"         // ⏹️
  };

  let ACTIVE = null;
  const STATE = new WeakMap();

  function chunkText(text, maxLen = 180) {
    const clean = (text || '').replace(/\s+/g, ' ').trim();
    if (!clean) return [];
    const sentences = clean
      .replace(/([.!?:;])\s+(?=[A-Z0-9“"(\[])/g, '$1|')
      .split('|');
    const chunks = [];
    let buf = '';
    for (const s of sentences) {
      const candidate = buf ? (buf + ' ' + s) : s;
      if (candidate.length <= maxLen) {
        buf = candidate;
      } else {
        if (buf) chunks.push(buf);
        if (s.length <= maxLen) {
          buf = s;
        } else {
          for (let i = 0; i < s.length; i += maxLen) {
            chunks.push(s.slice(i, i + maxLen));
          }
          buf = '';
        }
      }
    }
    if (buf) chunks.push(buf);
    return chunks;
  }

  // Build queue: if target contains <li>, read each item with a tiny gap
  function makeQueueFromTarget(el, maxLen = 180, gapMs = 250) {
    const lis = el.querySelectorAll && el.querySelectorAll('li');
    if (lis && lis.length) {
      const queue = [];
      Array.from(lis).forEach((li, i) => {
        const t = (li.innerText || '').replace(/\s+/g, ' ').trim();
        if (t) queue.push(t);
        if (i < lis.length - 1) queue.push({ gap: gapMs });
      });
      return queue;
    }
    return chunkText(el.innerText || '', maxLen);
  }

  function ensureState(btn) {
    let s = STATE.get(btn);
    if (!s) {
      s = { state: 'idle', stopBtn: null, queue: [], index: 0, utt: null, targetId: btn.getAttribute('data-target') };
      STATE.set(btn, s);
    }
    return s;
  }

  function setLabel(btn, key) { btn.textContent = LABELS[key]; }

  function ensureStopButton(btn, s) {
    if (s.stopBtn) return;
    const b = document.createElement('button');
    b.className = 'ra-stop';
    b.type = 'button';                         // avoid form submits on mobile
    b.style.marginLeft = '6px';
    b.textContent = LABELS.stop;
    const handler = (e) => { e.stopPropagation(); hardStop(btn); };
    b.addEventListener('click', handler);
    b.addEventListener('touchend', handler, { passive: true }); // iOS tap reliability
    btn.insertAdjacentElement('afterend', b);
    s.stopBtn = b;
  }

  function start(btn) {
    const s = ensureState(btn);
    const el = document.getElementById(s.targetId);
    if (!el) return;
    const raw = (el.innerText || '').trim();
    if (!raw) return;

    if (ACTIVE && ACTIVE !== btn) hardStop(ACTIVE);
    try { speechSynthesis.cancel(); } catch(_){}

    s.queue = makeQueueFromTarget(el, 180);
    s.index = 0;
    s.state = 'playing';
    setLabel(btn, 'pause');
    ensureStopButton(btn, s);
    ACTIVE = btn;

    setTimeout(() => speakNext(btn), 0);
  }

  function speakNext(btn) {
    const s = ensureState(btn);
    if (s.state !== 'playing') return;
    if (s.index >= s.queue.length) { reset(btn); return; }

    const next = s.queue[s.index++];
    if (typeof next === 'object' && next && next.gap) {
      setTimeout(() => speakNext(btn), next.gap);
      return;
    }

    const part = next;
    s.utt = new SpeechSynthesisUtterance(part);
    try { s.utt.lang = document.documentElement.lang || undefined; } catch(_){}
    s.utt.onend = () => {
      if (s.state === 'playing') {
        if (s.index < s.queue.length) speakNext(btn);
        else reset(btn);
      }
    };
    s.utt.onerror = () => { if (s.state === 'playing') speakNext(btn); };
    speechSynthesis.speak(s.utt);
  }

  function pause(btn) {
    const s = ensureState(btn);
    if (s.state !== 'playing') return;
    try { if (speechSynthesis.speaking && !speechSynthesis.paused) speechSynthesis.pause(); } catch(_){}
    s.state = 'paused';
    setLabel(btn, 'resume');
  }

  function resume(btn) {
    const s = ensureState(btn);
    if (s.state !== 'paused') return;
    try {
      if (speechSynthesis.paused) speechSynthesis.resume();
      else if (!speechSynthesis.speaking) speakNext(btn);
    } catch(_){}
    s.state = 'playing';
    setLabel(btn, 'pause');
  }

  // Mobile-safe hard stop (iOS/WebKit quirks)
  function hardStop(btn) {
    const s = ensureState(btn);
    s.state = 'idle';
    s.queue = [];
    s.index = 0;
    if (s.utt) { try { s.utt.onend = null; s.utt.onerror = null; } catch(_){} }
    try { if (speechSynthesis.paused) speechSynthesis.resume(); } catch(_){}
    try { speechSynthesis.cancel(); } catch(_){}
    setTimeout(() => { try { speechSynthesis.cancel(); } catch(_){} }, 0);
    reset(btn);
  }

  function reset(btn) {
    const s = ensureState(btn);
    s.state = 'idle';
    setLabel(btn, 'read');
    if (s.stopBtn) { s.stopBtn.remove(); s.stopBtn = null; }
    s.queue = []; s.index = 0; s.utt = null;
    if (ACTIVE === btn) ACTIVE = null;
  }

  // Event delegation (works even if posts inject late)
  document.addEventListener('click', (ev) => {
    const btn = ev.target.closest('.ra-btn[data-target]');
    if (!btn) return;
    ev.preventDefault();
    const s = ensureState(btn);
    if (s.state === 'idle') start(btn);
    else if (s.state === 'playing') pause(btn);
    else if (s.state === 'paused') resume(btn);
  });

  // Label buttons present now…
  function labelExisting() {
    document.querySelectorAll('.ra-btn[data-target]').forEach(btn => setLabel(btn, 'read'));
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', labelExisting, { once: true });
  } else {
    labelExisting();
  }

  // …and any that appear later
  const mo = new MutationObserver((muts) => {
    for (const m of muts) {
      for (const node of m.addedNodes || []) {
        if (node.nodeType !== 1) continue;
        if (node.matches && node.matches('.ra-btn[data-target]')) setLabel(node, 'read');
        if (node.querySelectorAll) node.querySelectorAll('.ra-btn[data-target]').forEach(el => setLabel(el, 'read'));
      }
    }
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });

  window.addEventListener('beforeunload', () => { try { speechSynthesis.cancel(); } catch(_){} });
})();
//]]>
</script>
That’s it! Now every button like .ra-btn[data-target] will control its section with Pause/Resume and a mobile‑safe Stop. Lists get natural pauses between items—no punctuation required.

Troubleshooting

  • Button still says “Read Aloud” (no 🔊): your theme script didn’t run. Make sure it’s inserted before </body> and not inside AMP pages.
  • Stop doesn’t work on phone: ensure your script has the hardStop function (resume→cancel + double‑cancel) and the Stop button has type="button" plus a touchend listener.
  • XML error while saving theme: keep the //<![CDATA[ ... //]]> lines exactly as shown.

Copy helper (used on this page)

This tiny script powers the “Copy code” buttons on this post. You can reuse it in other posts if you like.

<script>
(() => {
  function copyText(el) {
    const text = el.innerText;
    if (navigator.clipboard && navigator.clipboard.writeText) {
      return navigator.clipboard.writeText(text);
    }
    // Fallback
    const ta = document.createElement('textarea');
    ta.value = text; document.body.appendChild(ta);
    ta.select(); document.execCommand('copy'); ta.remove();
    return Promise.resolve();
  }
  document.addEventListener('click', (e) => {
    const btn = e.target.closest('.copy-btn');
    if (!btn) return;
    const code = document.querySelector(btn.getAttribute('data-copy'));
    if (!code) return;
    copyText(code).then(() => {
      const old = btn.textContent;
      btn.textContent = 'Copied!';
      setTimeout(() => btn.textContent = 'Copy code', 900);
    });
  });
})();
</script>

No comments: