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.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.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_reactionevents to every open Google Slides tab viachrome.tabs.sendMessage - Relays
start_session,stop_session, andslide_changedmessages 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.
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.
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: