Slide tracking

Every reaction gets tagged with a slide number, so a speaker can later see which slide got the biggest reaction. That number comes from a parallel data path, separate from the emoji tap itself: the extension watches the slide deck, reports changes up to the server, and the server broadcasts the current slide back down to the attendee’s page.

The adapter registry

Different presentation tools expose the current slide number differently, so the extension picks an adapter based on the page URL.

adapters/index.js
function getAdapter(url) {
  if (url.includes("docs.google.com/presentation")) return GoogleSlidesAdapter;
  return { getSlide: () => 0 };  // fallback for unknown platforms
}
How the Google Slides adapter reads the slide number

The Google Slides adapter reads the slide number from an accessibility element's aria-label, searching both the main document and any accessible same-origin iframes:

adapters/google_slides.js
const el = doc.querySelector('.punch-viewer-svgpage-a11yelement[aria-label*="Slide"]');
// aria-label matches /^Slide (\d+)/
    

That accessibility element only exists once the slideshow is actually running, in fullscreen or windowed presentation mode. It isn't present in the Slides editor view, so slide tracking does nothing until the speaker actually starts presenting.

This approach is inherently a little brittle, since it depends on Google's DOM structure rather than a first-party API, but it's the only option available. Fixture-based Jest tests snapshot the relevant DOM so a Google-side change gets caught before it ships.

Polling, not watching

The content script polls the adapter on an interval and reports changes up to the background worker, rather than pushing straight to the Channel.

content.js
function checkSlide() {
  const slide = adapter.getSlide();
  if (slide !== currentSlide) {
    currentSlide = slide;
    chrome.runtime.sendMessage({ type: "SLIDE_CHANGED", slide: currentSlide }, () => {
      void chrome.runtime.lastError;
    });
  }
}
checkSlide();
slideInterval = setInterval(checkSlide, 500);

The background worker is the one that actually pushes to the channel, with channel.push('slide_changed', { slide: currentSlide }), and it also tells the popup, which shows the current slide number live. That popup readout doubles as a sanity check that the adapter is actually reading the deck correctly.

Server-side handling

reaction_channel.ex
def handle_in("slide_changed", %{"slide" => slide}, socket)
    when is_integer(slide) and slide >= 0 do
  SpeechwaveWeb.Endpoint.broadcast!("slides:#{socket.assigns.talk.slug}", "slide_changed", %{slide: slide})
  {:reply, :ok, socket}
end

Slide 0 is the sentinel for unknown/general, and it’s broadcast just like any other slide number. This matters because getSlide() reports 0 whenever the accessibility element isn’t present — not just before presenting starts, but also if the speaker leaves Slideshow mode mid-session (back to the editor, or switching windows). Broadcasting 0 in that case resets every attendee’s current_slide back to the general pool, so reactions sent while nothing is actually being presented don’t get misattributed to whatever slide was on screen last. A second clause still rejects non-integer or negative payloads without broadcasting. TalkLive subscribes to the slides:#{slug} topic (see the mount walkthrough in Emoji journey) and updates its current_slide assign, so the next reaction tap carries the right slide number. There’s a small, natural lag between a slide change and the first reaction tagged with it, which is expected given the polling interval.

Where the code lives


This site uses Just the Docs, a documentation theme for Jekyll.