Accessibility Reader Widget

The Accessibility Reader Widget is a lightweight, high-performance text-to-speech tool designed to make blog content more inclusive and easier to consume.

Operating entirely within the user's web browser via the native Web Speech API, it avoids heavy external dependencies or tracking scripts. When activated by clicking a discreet floating button, the widget deploys a clean audio control bar at the bottom of the screen.

It intelligently targets and extracts the core text of an article while automatically filtering out layout clutter, styling scripts, navigation menus, and image captions to ensure a fluid, uncompromised playback experience.

Built with user preference in mind, the control panel offers customisable settings including an adjustable playback speed slider.

The engine actively prioritises accents based on the visitor’s operating system library, splitting the text into natural sentence fragments to eliminate processing timeouts on longer posts.

Whether you are on the go or just prefer to listen while you relax, this feature is here to make your experience as easy, and enjoyable as possible.
1.0x
Accessibility Reader Widget
<div id="accessibility-widget-container">
  <button id="tts-launcher" title="Launch Audio Reader">
    <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
    <span class="tts-launch-text">Listen</span>
  </button>

  <div id="tts-control-bar" class="tts-hidden">
    <div class="tts-pill">
      
      <div class="tts-controls">
        <button id="tts-play-btn" class="tts-icon-btn tts-primary" title="Play">
          <svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
        </button>
        <button id="tts-pause-btn" class="tts-icon-btn" title="Pause" disabled>
          <svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
        </button>
        <button id="tts-stop-btn" class="tts-icon-btn" title="Stop" disabled>
          <svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M6 6h12v12H6z"/></svg>
        </button>
      </div>

      <div class="tts-divider"></div>

      <div class="tts-speed-control" title="Adjust Speed">
        <input type="range" id="tts-speed-input" min="0.5" max="1.5" step="0.1" value="1">
        <span id="tts-speed-display">1.0x</span>
      </div>

      <div class="tts-divider"></div>

      <button id="tts-close" class="tts-icon-btn" title="Close Reader">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
      </button>

    </div>
  </div>
</div>

<style>
  #accessibility-widget-container {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  }

  /* Word Highlight Styling */
  .tts-active-word {
    background-color: #000000 !important;
    color: #ffffff !important;
    border-radius: 3px;
    padding: 2px 0;
    box-shadow: 0 0 0 2px #000000;
    transition: background-color 0.1s, color 0.1s;
  }

  /* Launcher Button */
  #tts-launcher {
    position: fixed;
    right: 0;
    top: 60%; 
    transform: translateY(-50%);
    z-index: 99998;
    background-color: #222222;
    color: #ffffff;
    border: none;
    border-radius: 8px 0 0 8px;
    padding: 12px 14px;
    cursor: pointer;
    font-size: 15px;
    display: flex;
    align-items: center;
    gap: 8px;
    transition: background-color 0.2s, padding 0.3s;
    box-shadow: -2px 4px 12px rgba(0,0,0,0.2);
  }
  
  #tts-launcher .tts-launch-text {
    max-width: 0;
    overflow: hidden;
    white-space: nowrap;
    transition: max-width 0.3s ease;
    opacity: 0;
    font-weight: 600;
  }
  
  #tts-launcher:hover {
    padding-left: 20px;
    background-color: #0056b3;
  }
  
  #tts-launcher:hover .tts-launch-text {
    max-width: 80px;
    opacity: 1;
  }

  /* Minimalist Floating Pill Layout */
  #tts-control-bar {
    position: fixed;
    bottom: 30px;
    left: 50%;
    transform: translateX(-50%) translateY(150px);
    z-index: 100000;
    transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  }
  
  #tts-control-bar.tts-visible {
    transform: translateX(-50%) translateY(0);
  }
  
  .tts-pill {
    background-color: #1a1a1a;
    border: 1px solid #333;
    border-radius: 50px;
    padding: 8px 24px;
    display: flex;
    align-items: center;
    gap: 16px;
    box-shadow: 0 10px 30px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05);
  }

  .tts-controls {
    display: flex;
    align-items: center;
    gap: 12px;
  }

  .tts-icon-btn {
    background: none;
    border: none;
    color: #999999;
    cursor: pointer;
    padding: 4px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    transition: color 0.2s, transform 0.1s, background-color 0.2s;
  }

  .tts-icon-btn:hover:not(:disabled) {
    color: #ffffff;
    transform: scale(1.05);
  }

  .tts-icon-btn:active:not(:disabled) {
    transform: scale(0.95);
  }

  .tts-icon-btn:disabled {
    opacity: 0.3;
    cursor: not-allowed;
  }

  .tts-icon-btn.tts-primary {
    color: #007bff;
  }

  .tts-icon-btn.tts-primary:hover:not(:disabled) {
    color: #3395ff;
  }

  .tts-divider {
    width: 1px;
    height: 24px;
    background-color: #333333;
  }

  .tts-speed-control {
    display: flex;
    align-items: center;
    gap: 10px;
  }

  #tts-speed-input {
    cursor: pointer;
    accent-color: #007bff;
    width: 80px;
  }

  #tts-speed-display {
    color: #cccccc;
    font-size: 13px;
    font-weight: 600;
    min-width: 32px;
  }

  @media (max-width: 480px) {
    .tts-pill {
      padding: 8px 16px;
      gap: 10px;
    }
    #tts-speed-input {
      width: 60px;
    }
  }
</style>

<script>
(function() {
  // Removed 'main' to prevent it from selecting the entire page wrapper in Blogger
  const CONTENT_SELECTORS = [
    '.post-body', '.entry-content', 'article', '[itemprop="articleBody"]', '.main-content'
  ];

  // DOM Elements
  const launcher = document.getElementById('tts-launcher');
  const controlBar = document.getElementById('tts-control-bar');
  const closeBtn = document.getElementById('tts-close');
  const playBtn = document.getElementById('tts-play-btn');
  const pauseBtn = document.getElementById('tts-pause-btn');
  const stopBtn = document.getElementById('tts-stop-btn');
  const speedInput = document.getElementById('tts-speed-input');
  const speedDisplay = document.getElementById('tts-speed-display');

  // Engine State
  const synth = window.speechSynthesis;
  let isPlaying = false;
  let isPaused = false;
  let isCancelling = false; // Prevents skipping sentences when changing speed/stopping
  let currentUtterance = null; // Prevents garbage collection of the utterance
  
  // Highlighting State
  let targetContainer = null;
  let sentencesData = []; 
  let currentSentenceIndex = 0;
  let currentHighlightedSpan = null;

  function updateUIState() {
    if (isPlaying && !isPaused) {
      playBtn.disabled = true;
      pauseBtn.disabled = false;
      stopBtn.disabled = false;
    } else if (isPaused) {
      playBtn.disabled = false;
      pauseBtn.disabled = true;
      stopBtn.disabled = false;
    } else {
      playBtn.disabled = false;
      pauseBtn.disabled = true;
      stopBtn.disabled = true;
    }
  }

  function removeCurrentHighlight() {
    if (currentHighlightedSpan) {
      currentHighlightedSpan.classList.remove('tts-active-word');
      currentHighlightedSpan = null;
    }
  }

  function clearAllHighlights() {
    removeCurrentHighlight();
    if (targetContainer) {
      const activeSpans = targetContainer.querySelectorAll('.tts-active-word');
      activeSpans.forEach(span => span.classList.remove('tts-active-word'));
    }
  }

  function prepareDOMForHighlighting() {
    for (const selector of CONTENT_SELECTORS) {
      const el = document.querySelector(selector);
      if (el && el.innerText.trim().length > 150) {
        targetContainer = el;
        break;
      }
    }
    if (!targetContainer) targetContainer = document.body;

    if (targetContainer.dataset.ttsWrapped) return;

    const walker = document.createTreeWalker(targetContainer, NodeFilter.SHOW_TEXT, {
      acceptNode: function(node) {
        const parent = node.parentElement;
        if (!parent) return NodeFilter.FILTER_REJECT;

        // The Aggressive Layout Exclusion Fix
        const excludedSelectors = 'script, style, noscript, #accessibility-widget-container, button, input, textarea, #comments, .exclude-tts, .universal-code-box, pre, code, header, nav, footer, aside, .blog-description, .Header';
        
        if (parent.closest(excludedSelectors)) {
          return NodeFilter.FILTER_REJECT;
        }

        // The Hidden Metadata Fix: Ignores text that is visually hidden by CSS
        const style = window.getComputedStyle(parent);
        if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
          return NodeFilter.FILTER_REJECT;
        }

        if (node.nodeValue.trim() === '') {
          return NodeFilter.FILTER_SKIP;
        }
        
        return NodeFilter.FILTER_ACCEPT;
      }
    }, false);

    const nodesToReplace = [];
    let node;
    while (node = walker.nextNode()) nodesToReplace.push(node);

    nodesToReplace.forEach(textNode => {
      const words = textNode.nodeValue.split(/(\s+)/);
      const fragment = document.createDocumentFragment();
      
      words.forEach(word => {
        if (/\S/.test(word)) {
          const span = document.createElement('span');
          span.className = 'tts-trackable-word';
          span.textContent = word;
          fragment.appendChild(span);
        } else {
          fragment.appendChild(document.createTextNode(word));
        }
      });
      textNode.parentNode.replaceChild(fragment, textNode);
    });

    targetContainer.dataset.ttsWrapped = 'true';
    buildSentenceQueue();
  }

  function buildSentenceQueue() {
    sentencesData = [];
    if (!targetContainer) return;

    const allSpans = Array.from(targetContainer.querySelectorAll('.tts-trackable-word'));
    let currentSentenceText = "";
    let currentSentenceSpans = [];

    allSpans.forEach(span => {
      currentSentenceSpans.push(span);
      currentSentenceText += span.textContent + " ";
      
      if (/[.!?]$/.test(span.textContent.trim())) {
        sentencesData.push({ text: currentSentenceText, spans: currentSentenceSpans });
        currentSentenceText = "";
        currentSentenceSpans = [];
      }
    });

    if (currentSentenceSpans.length > 0) {
      sentencesData.push({ text: currentSentenceText, spans: currentSentenceSpans });
    }
  }

  function readNextSentence() {
    if (currentSentenceIndex >= sentencesData.length) {
      stopReading();
      return;
    }

    const sentenceData = sentencesData[currentSentenceIndex];
    
    currentUtterance = new SpeechSynthesisUtterance(sentenceData.text);
    currentUtterance.rate = parseFloat(speedInput.value);
    
    currentUtterance.onboundary = function(event) {
      if (event.name === 'word' || event.charIndex !== undefined) {
        const charIndex = event.charIndex;
        let runningLength = 0;
        
        for (let i = 0; i < sentenceData.spans.length; i++) {
          const span = sentenceData.spans[i];
          const spanTextLength = span.textContent.length + 1;
          
          if (charIndex >= runningLength && charIndex < runningLength + spanTextLength) {
            removeCurrentHighlight();
            span.classList.add('tts-active-word');
            currentHighlightedSpan = span;

            const rect = span.getBoundingClientRect();
            const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
            if (rect.top < viewHeight * 0.25 || rect.bottom > viewHeight * 0.75) {
              span.scrollIntoView({ behavior: 'smooth', block: 'center' });
            }
            break;
          }
          runningLength += spanTextLength;
        }
      }
    };

    currentUtterance.onend = function() {
      removeCurrentHighlight();
      if (isPlaying && !isPaused && !isCancelling) {
        currentSentenceIndex++;
        readNextSentence();
      }
    };

    currentUtterance.onerror = function(e) {
      removeCurrentHighlight();
      if (isPlaying && !isPaused && !isCancelling && e.error !== 'interrupted' && e.error !== 'canceled') {
        currentSentenceIndex++;
        readNextSentence();
      }
    };

    synth.speak(currentUtterance);
  }

  function startReading() {
    if (sentencesData.length === 0) {
      prepareDOMForHighlighting();
      if (sentencesData.length === 0) {
        alert('Could not detect readable article text.');
        return;
      }
    }

    if (isPaused) {
      synth.resume();
      isPaused = false;
    } else {
      isCancelling = true;
      synth.cancel(); 
      isCancelling = false;
      
      isPlaying = true;
      readNextSentence();
    }
    updateUIState();
  }

  function pauseReading() {
    synth.pause();
    isPaused = true;
    updateUIState();
  }

  function stopReading() {
    isCancelling = true;
    synth.cancel();
    isCancelling = false;
    
    isPlaying = false;
    isPaused = false;
    currentSentenceIndex = 0;
    clearAllHighlights();
    updateUIState();
  }

  // Interactivity Events
  launcher.addEventListener('click', () => {
    controlBar.classList.add('tts-visible');
  });

  closeBtn.addEventListener('click', () => {
    stopReading();
    controlBar.classList.remove('tts-visible');
  });

  playBtn.addEventListener('click', startReading);
  pauseBtn.addEventListener('click', pauseReading);
  stopBtn.addEventListener('click', stopReading);

  speedInput.addEventListener('input', (e) => {
    speedDisplay.textContent = parseFloat(e.target.value).toFixed(1) + 'x';
    if (isPlaying && !isPaused) {
      isCancelling = true;
      synth.cancel();
      isCancelling = false;
      readNextSentence();
    }
  });

  window.addEventListener('beforeunload', () => {
    synth.cancel();
  });
})();
</script>

Post a Comment

0 Comments

Notice:
Comments are moderated and may not appear immediately. Please keep your comments respectful, and relevant to the post. Spam will not be tolerated. My site. My rules.

Post a Comment (0)