Chrome extension

The Chrome extension is what turns a broadcast reaction into an emoji floating across a slide. It lives in its own repository, speechwave-live/chrome-extension, and it’s built as three cooperating pieces rather than one script.

🎛️ Popup
⚙️ Background worker
🖥️ Phoenix server
⚙️ Background worker
📄 Content script
🎉 Overlay on slide
Popup

popup.html and popup.js. The speaker enters a talk slug and API key here, validated client-side as long hex string. Everything the speaker does gets sent to the background worker, not straight to the content script.

Content script

content.js, injected into Google Slides pages. Renders the emoji overlay, sized and animated per the remote config described below, and polls the current slide (see Slide tracking). It owns no socket connection of its own.

The background service worker does the heavy lifting

background/background.js owns the Phoenix Socket and Channel connection for the extension’s entire lifetime, not just for one open tab. It:

  • Connects to wss://speechwave.live/socket
  • Joins reactions:${slug} with the API key
  • Relays incoming new_reaction events to every open Google Slides tab via chrome.tabs.sendMessage
  • Relays start_session, stop_session, and slide_changed messages on behalf of the popup and content script

Because Chrome can kill and restart a Manifest V3 service worker independently of any open tab, the background worker has explicit reconnect and rejoin logic, and it guards against acting on a stale socket left over from before a restart.

Fireworks animation

When enough reactions of the same emoji land in a short window, the content script triggers a fireworks burst instead of just the usual float animation.

Why the compound trigger condition?

Fireworks fire when an emoji's in-flight count crosses a minimum threshold and that emoji makes up a large enough share of everything currently animating. Requiring both conditions means a single emoji spamming the screen triggers fireworks, but a big mixed burst of many different emojis doesn't, since no single emoji dominates it.

A global cooldown keeps it to one burst at a time, and in-flight counts are tracked by incrementing on spawn and decrementing when the animation ends, so the count naturally settles as animations finish. The trigger logic is extracted into a pure function in lib/fireworks.js, dual-exported for both Jest and the browser, the same pattern used by the extension's adapter modules.

The burst itself renders sixteen particles using the Web Animations API rather than CSS keyframes, because each particle needs its own computed target position, something plain CSS keyframes can't parameterize per element. A safety timeout resets the "fireworks active" flag a couple of seconds later, in case an animation-end event doesn't fire, for example if the overlay gets re-parented mid-transition.

The speaker turns fireworks on or off from the account Settings page on speechwave.live, not from the popup. See the next section for how that setting, and the overlay’s size, actually gets to the extension.

Remote config: overlay size and fireworks

Two things a presenter controls, overlay size and whether fireworks are enabled, live on the account Settings page instead of anywhere in the extension. The backend also ships a batch of animation tuning constants alongside them, font size ratios, firework spread distance, and so on, that used to be hardcoded in content.js. Bundling all of it into the channel join reply means the team can retune how the overlay looks with a backend deploy. No new Chrome Web Store submission needed.

How the config actually reaches a slide

The reactions channel's join reply carries a {settings, tuning} payload built by join_payload/1 (see WebSockets), sourced from the presenter's saved settings and Speechwave.ExtensionTuning.current/0. background.js caches that payload in memory and in chrome.storage.local, so it survives a Manifest V3 service worker restart, then broadcasts it to every open Slides tab as a SET_REMOTE_CONFIG message. A tab that's already open when the join happens, or reloads mid-session, asks for it directly with a GET_REMOTE_CONFIG request instead of waiting on the next broadcast.

If a payload is ever missing a value, an old backend that hasn't deployed this yet, or a hand-edited tuning map mid-change, the content script fills in just that key from a shared set of defaults rather than failing outright. Both the background worker and the content script fall back to the same hardcoded defaults, kept in one file, lib/default_remote_config.js, so the extension still renders something sensible before its first successful join.

content.js sizes the overlay box as a percentage of the slide’s actual rendered dimensions, slideDimension * (overlay_size_percent / 100), rather than a fixed pixel box. Emoji font size, rise distance, and firework spread all scale off that same box using the ratios from the tuning payload, so a wider slide gets a proportionally wider overlay instead of the same absolute size crammed into more or less room.

Fullscreen re-parenting

Google Slides fullscreen mode creates a new stacking context, which makes a position: fixed overlay attached to <body> invisible. The extension handles this by re-parenting the overlay element into the fullscreen element on fullscreenchange, and moving it back to <body> on exit.

Where the code lives

All of this lives in the separate chrome-extension repository:


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