How the extension works under the hood. For the general overview, see README.md.
There are three moving parts — content script, popup, and a background script
that only exists for the keyboard shortcut. Settings flow through
chrome.storage; live data flows through a runtime Port. There is no server,
no offscreen document, and no page-world script injection.
entrypoints/content.ts — runs on every page (all_frames). Capture-phase
play/pause/ended/emptied listeners on document catch every media
element, including dynamically added ones — media events don’t bubble but do
capture, so no MutationObserver is needed. Resolves per-site setting
overrides by top-level hostname (via location.ancestorOrigins; Firefox
iframes fall back to their own hostname) and serves the popup’s live meter
over a runtime Port at ~30Hz.entrypoints/volume-processor.ts — AudioWorklet processor: passes audio
through untouched and posts one RMS value per 1024-sample window (~21ms at
48kHz) from the audio thread, so detection is immune to background-tab timer
throttling.lib/detector.ts — per-element audio graph and threshold logic.lib/controller.ts — per-element playback policy (when to speed up, mute,
and restore).lib/speed.ts — sets playbackRate and defends it against sites (YouTube)
whose ratechange handlers revert external rate changes, using a
capture-phase listener + stopImmediatePropagation + retry.entrypoints/background.ts — listens for the Ctrl+Shift+S command and
flips the enabled flag (per-site aware).entrypoints/popup/ + components/ — React popup (Tailwind v4 +
shadcn/ui): live canvas volume meter with threshold line, speed cards,
toggles, per-site override switch.MediaElementSource → VolumeProcessor (worklet) → DelayNode → GainNode → destination
│
└─ RMS per ~21ms window → threshold logic → controller
createMediaElementSource() permanently reroutes an element’s audio and can
only ever be called once per element — the graph is cached per element in a
WeakMap.setTargetAtTime, 15ms mute / 40ms unmute time
constants) to avoid clicks.The original Skip Silence used chrome.tabCapture on Chrome; that API is
unavailable to MV3 extensions. Element capture is the only viable MV3 path,
and it comes with known constraints handled in content.ts:
createMediaElementSource()
on such elements outputs pure silence and permanently kills the element’s
audio. We check currentSrc origin before attaching and refuse, surfacing
“can’t analyze” in the popup. Undetectable residual: same-origin URLs that
redirect cross-origin.el.mediaKeys != null, e.g. Netflix): skipped.duration === Infinity): skipped — speeding up the live
edge only causes stalls.play is composed: false).Settings object in chrome.storage.sync (single write per change,
sliders commit on release to respect sync write quotas).chrome.storage.local as
Record<hostname, Settings> — an override fully replaces the global
settings on that site. Keyed by top-level hostname so embedded players
follow the embedding site.withDefaults() merges stored objects with DEFAULT_SETTINGS so fields
added in updates get their defaults.storage.local, flushed every ~5s
(read-modify-write; a lost tick across tabs is acceptable).resume() on the
next user gesture — otherwise rerouted media would stay silent.lib/speed.ts if clicking at exactly 1.0 reappears.bun i
bun run dev # Chrome with the extension loaded
bun run build # .output/chrome-mv3
bun run build:firefox # .output/firefox-mv2
bun run compile # typecheck
Manual smoke test: serve test/media-test.html same-origin, hit
“Generate & play” — the rate readout should jump to the silence speed during
the 3s silence gaps and snap back for each tone.
Manual test matrix: YouTube lecture (skips in pauses, snaps back on speech,
survives YouTube’s own rate handling), Twitch VOD, plain <audio> podcast
page, cross-origin <video> (audio stays intact, popup shows “can’t
analyze”), Netflix (untouched, DRM status), disable mid-silence (rate and
volume restore), Firefox spot-check.