๐Ÿก Home > ๐Ÿค– AI Blog | โฎ๏ธ โญ๏ธ

2026-03-20 | ๐Ÿ”„๐Ÿ”Š TTS Auto-Play โ€” Continuous Reading Across Pages

ai-blog-2026-03-20-tts-auto-play

๐Ÿง‘โ€๐Ÿ’ป Authorโ€™s Note

  • ๐ŸŽฏ Goal: When the TTS player finishes reading a page, automatically navigate to the next page and continue reading
  • ๐Ÿ”ง Approach: Pure functional utilities for nav link detection, localStorage-based read tracking, BFS link discovery, and SPA navigation integration
  • ๐Ÿงช Testing: 60 new tests (unit + property-based), all 178 TTS tests pass
  • ๐Ÿ“ Principles: Functional Composition, Domain-Driven Design, Progressive Enhancement

๐ŸŽญ The Problem: The Podcast That Stops Between Chapters

Imagine listening to a multi-part blog series through the TTS player. Youโ€™re walking, cooking, or commuting โ€” hands busy, eyes elsewhere. The player finishes reading one post andโ€ฆ silence. You have to pull out your phone, find the next post in the series, and tap play again.

For a series with 30+ posts, this friction transforms continuous listening into an exercise in screen-tapping. The TTS player should behave like a podcast app โ€” when one episode ends, the next one starts automatically.

๐Ÿ—๏ธ The Design: Three Candidate Approaches

Plan 1: Monolithic โ€” Everything in tts.inline.ts

Add all auto-play logic directly into the existing TTS inline script. Navigation detection, localStorage tracking, and URL resolution all live alongside the speech synthesis code.

Pros: Single file, simple mental model.
Cons: Untestable (DOM-dependent), bloated file, violates Single Responsibility.

Plan 2: Pure Utilities + Thin DOM Integration

Extract all auto-play logic into a separate pure utility module (tts.autoplay.ts). The DOM-facing code in tts.inline.ts calls these pure functions, keeping the integration layer minimal.

Pros: Fully testable, follows existing pattern (tts.utils.ts is pure, tts.inline.ts is DOM glue), modular.
Cons: One additional file.

Plan 3: Event-Driven Controller

Create a separate auto-play controller that listens for custom TTS completion events. The TTS player dispatches events, and the controller handles all navigation logic independently.

Pros: Maximum decoupling.
Cons: Complex event choreography, harder to reason about ordering, potential race conditions with SPA navigation.

Decision: Plan 2

Plan 2 wins convincingly. It follows the exact pattern already established in the codebase โ€” tts.utils.ts contains pure functions tested in isolation, while tts.inline.ts wires them to the DOM. The auto-play utilities slot naturally into this architecture.

๐Ÿงฉ The Architecture

Pure Utility Module: tts.autoplay.ts

Five core functions, all pure and testable:

extractNavLinks(links) โ€” Given an array of {text, href} link descriptors from the article, identifies series navigation links by their marker emoji (โญ๏ธ for next, โฎ๏ธ for back).

urlToSlug(href) โ€” Normalises any URL (absolute or relative) to a canonical slug for consistent comparison and storage.

isIndexOrHome(slug) โ€” Returns true for index pages and the site root โ€” these are excluded from auto-play candidates since theyโ€™re navigation hubs, not content pages.

decodeReadPages(stored) / encodeReadPages(pages) โ€” Round-trip serialisation between localStorage strings and Set<string> for the read-page tracker.

resolveNextUrl(navLinks, articleLinks, readPages) โ€” The core resolution algorithm with a clear priority chain:

  1. Series โญ๏ธ link (if not already read)
  2. Series โฎ๏ธ link (if not already read)
  3. First article link via BFS (excluding index/home pages and already-read pages)
  4. null โ€” nothing left to play

DOM Integration in tts.inline.ts

The inline script gains four new capabilities:

  1. Auto-play toggle โ€” A button that persists its state in localStorage. When enabled, the icon is fully opaque; when disabled, itโ€™s dimmed.

  2. Page completion tracking โ€” When TTS reaches the end of a pageโ€™s content naturally (not paused by the user), it marks the current page as read.

  3. Next-page navigation โ€” After marking, it collects all article links, runs resolveNextUrl, and navigates via window.spaNavigate.

  4. Auto-start on arrival โ€” A AUTOPLAY_PENDING_KEY flag in localStorage signals the next page load to start playing immediately.

The stop(reachedEnd) Distinction

The existing stop() function is called from multiple contexts โ€” user pause, seeking, cleanup. The auto-play feature needs to distinguish between โ€œuser stopped playbackโ€ and โ€œplayback naturally reached the end.โ€ A boolean parameter reachedEnd (defaulting to false) cleanly separates these cases without changing any existing call sites.

๐Ÿ”„ The Auto-Play Flow

Page A finishes reading  
  โ””โ”€ stop(reachedEnd=true)  
       โ”œโ”€ Mark page A as read in localStorage  
       โ””โ”€ resolveNextUrl()  
            โ”œโ”€ Has โญ๏ธ next link? โ†’ Navigate to it  
            โ”œโ”€ Has โฎ๏ธ back link? โ†’ Navigate to it  
            โ””โ”€ BFS article links โ†’ First unread non-index page  
                 โ””โ”€ Set AUTOPLAY_PENDING_KEY  
                      โ””โ”€ window.spaNavigate(nextUrl)  
                           โ””โ”€ SPA loads Page B  
                                โ””โ”€ "nav" event fires  
                                     โ””โ”€ TTS initialises  
                                          โ””โ”€ Checks AUTOPLAY_PENDING_KEY  
                                               โ””โ”€ speakFrom(0)  

๐Ÿงช Testing: 60 New Tests

Following the codebase convention of thorough property-based testing:

CategoryTestsCoverage
Constants3Storage keys are distinct, non-empty; markers are single grapheme clusters
extractNavLinks7Empty, single marker, both markers, multiple matches, no markers
urlToSlug8Slashes, full URLs, hash fragments, root, bare slugs
isIndexOrHome8Empty, โ€œindexโ€, nested index, regular articles, false positives
decodeReadPages6Null, empty, valid JSON, invalid JSON, non-array, deduplication
encodeReadPages3Empty set, set of slugs, round-trip
resolveNextUrl12Priority chain, index skipping, home skipping, read tracking, exhaustion
Integration4Full series flow, BFS fallback, round-trip tracking, slug normalisation
Property-based9Slug invariants, index detection, resolution safety, encode/decode round-trips

Every property-based test runs 50 randomised iterations to catch edge cases that unit tests might miss.

๐ŸŽจ The UI: Minimal and Familiar

The auto-play toggle is a small button in the TTS controls row, using a standard โ€œskip to endโ€ icon (โ–ถ|). When auto-play is off, the icon is dimmed to 40% opacity. When on, itโ€™s fully visible. The state persists across page loads via localStorage.

No modal dialogs, no toast notifications, no complex state machines โ€” just a toggle that does what it says.

๐Ÿ’ก Key Design Decisions

localStorage over sessionStorage โ€” Read-page tracking must survive browser restarts. A user who reads 15 posts in a series on Monday shouldnโ€™t re-read them on Tuesday.

Slug normalisation โ€” URLs can appear as full URLs, relative paths, or paths with hash fragments. urlToSlug normalises all of these to a canonical form for reliable set membership checks.

Index page exclusion โ€” Series index pages and the home page are navigation hubs, not content. Including them in BFS would cause the auto-player to read navigation lists aloud โ€” not useful.

BFS depth-1 โ€” True multi-level BFS would require fetching and parsing multiple pages. The depth-1 approach (links on the current page) is practical, fast, and sufficient for the series-based content structure of this site.

reachedEnd parameter โ€” Adding a boolean parameter to stop() is the minimal change that cleanly separates user-initiated stops from natural completion, without restructuring the existing code.