“A Podcast Player That Survives Page Navigation”

Hugo is static. When you click a link, the page reloads. Every audio player resets. Podcast listeners lose their position. I wanted a player that keeps playing across every page transition, persists playback state, and works with radio streams and podcast platforms without requiring a JavaScript framework.

How it works

Wavecast provides two custom Web Components: podcast-player for inline use and podcast-footer for a sticky bottom bar. Both are bidirectionally synced. Pausing any player pauses all audio. Only one source plays at a time. Position, volume, mute, and playback speed are saved to sessionStorage and restored across page navigations.

The player ships as both a Hugo module and a Hugo theme. Drop a shortcode into any page and it renders a fully functional player with no additional setup.

Technical decisions

Web Components instead of React or Vue. A Hugo site is HTML. Adding a JavaScript framework just to play audio is overkill. Web Components work natively in every modern browser with zero dependencies. The Shadow DOM isolates styles so the player never collides with the host site’s CSS.

::part() selectors for theming. The Shadow DOM prevents accidental style leakage, but it also prevents intentional theming. The ::part() CSS pseudo-element lets users style individual elements (play button, progress bar, volume slider) from outside the Shadow DOM without forking the component. Twelve CSS custom properties control colors, sizing, and focus rings for light and dark themes.

Bidirectional sync via custom events. When any player pauses, it dispatches a podcast-pause event that bubbles up the DOM. Every other player instance listens for these events and synchronizes. This works regardless of whether the players are on the same page or separated by an iframe.

Source adapters as a plugin system. Each audio source type (local files, AzuraCast radio stations, iVoox episodes) gets its own adapter class that implements fetchStreamUrl() and optionally fetchMetadata(). Auto-detection rules match URLs to adapters. Adding a new source type means implementing one class and registering it, no changes to the core player.

Navigation persistence across frameworks. The player survives Turbolinks, Turbo, htmx, and vanilla page loads. It uses data-turbolinks-permanent, data-turbo-permanent, and hx-preserve attributes for framework-specific persistence, plus a sessionStorage fallback for everything else. Position restore accounts for elapsed time if audio was playing when the user navigated away.

What I learned

Building a Web Component that works everywhere is harder than it looks. The Shadow DOM means no global styles, so every button, slider, and tooltip had to be styled from scratch. Cross-browser audio APIs behave differently: Safari throttles timeupdate events, Firefox doesn’t fire loadedmetadata reliably for certain codecs, and Chrome’s autoplay policy changes based on user engagement.

The test suite caught most of these. Vitest unit tests cover the core logic. Playwright E2E tests spin up an actual Hugo site with example players and verify that play, pause, seek, volume, and sync all work across browsers.

View on GitHub – Live demo