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.
function getAdapter(url) {
if (url.includes("docs.google.com/presentation")) return GoogleSlidesAdapter;
return { getSlide: () => 0 }; // fallback for unknown platforms
}
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:
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.
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
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.