Skip to content

feat: modernize the player and recover from stream dropouts - #396

Open
ahmaxed wants to merge 5 commits into
mainfrom
feat/modernize-player
Open

ahmaxed wants to merge 5 commits into
mainfrom
feat/modernize-player

Conversation

@ahmaxed

@ahmaxed ahmaxed commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Closes #301. This also covers what #315 set out to do, so that one can probably be closed.

Why

Listeners have been reporting that Code Radio cuts out every now and then. Most of that was on the server side (the old Liquidsoap was dropping its feed at song changes, which is fixed by the AzuraCast upgrade we just switched to), but the client made it worse: when the stream stalled, the player just sat there in "playing" state with no sound until you hit pause and play again.

While I was in there I also found that Firefox and Safari users were always getting the 64kbps stream and no album art, because the "fast connection" check relied on navigator.connection, which only exists in Chromium.

On top of that the tooling had aged out. CRA is deprecated, Node 20 is end of life, and CI was pinned to ubuntu-20.04 runners that GitHub has retired, so the checks weren't actually running.

What changed

Playback

  • The player now watches whether playback is actually moving forward. If it stalls, errors or ends, it reconnects with backoff (1s, 2s, 4s… up to 30s). The first retry uses the same stream and later retries switch to the other one. A "Reconnecting…" status shows while this happens.
  • It picks 128kbps unless the browser explicitly says the connection is slow or data saver is on.
  • Fades use a Web Audio gain node, so they work on iOS as well.
  • If the AudioContext gets suspended (Safari after sleep, switching audio devices), it's resumed. Before, the audio kept "playing" without any sound.
  • Rejected play() calls (autoplay blocked) are handled instead of leaving the button stuck.
  • Lock screen and media key controls now work, with song info, through the Media Session API.

Now playing

  • The SSE connection reconnects if the server closes it, then resyncs from the JSON snapshot. Errors are now breadcrumbs instead of one Sentry exception per hiccup.
  • The progress bar is based on the server's own clock rather than the listener's, and song changes are delayed by the audio buffer. The title should now change at the same time as the music, instead of a few seconds early.
  • The listener count shows unique listeners rather than connections. A listener with two tabs open was being counted twice.

Code and tooling

  • Vite instead of CRA, React 19 with createRoot, Node 24.
  • Class components are now function components with hooks. The audio logic lives in useAudioPlayer, the metadata in useNowPlaying, and App.js is down from 600 lines to about 50.
  • The visualizer draws with refs instead of setting React state on every frame. It's sharp on HiDPI screens, resizes with the window and turns off with prefers-reduced-motion. The background video does too.
  • Dropped react-scripts, @sentry/tracing, react-device-detect, react-page-visibility, store, FontAwesome and the prop-types usage. Sentry uses the current browserTracingIntegration and samples 5% of traces instead of 100%.
  • ESLint 10 with the React hooks rules, which wasn't actually running before. Prettier now checks the whole repo.
  • The Discord widget script is pinned to an exact version.

Before merging

  • Netlify: netlify.toml now publishes dist and Node comes from .nvmrc. If a NODE_VERSION env var is set in the Netlify dashboard it'll override .nvmrc, so it's worth checking. The build image should be the current Ubuntu one, not focal.
  • Sentry: REACT_APP_SENTRY_DSN still works, so nothing needs to change there. VITE_SENTRY_DSN is the new name if we want to rename it at some point.
  • The deploy preview is the best place to try it: play, pause, switch streams, lock screen controls on a phone, and Safari.

Testing

  • Unit tests (Vitest) cover stream ordering and failover, SSE message parsing, the progress calculation, connection detection and the hotkeys.
  • Cypress now has a recovery spec with two cases. One returns 503s and checks that the player retries and then falls back to the 64k stream. The other throttles the 128k stream to a trickle and checks that the player notices the stall and moves to 64k by itself. All Cypress tests pass locally in Chrome and Firefox. The play and stall tests use the real stream, like the existing play test did.
  • I also checked by hand that the visualizer gets real audio data in both browsers, since a CORS problem there would mean silent playback.
  • I compared screenshots against production on desktop and mobile. One thing that came up: .sr-only used to come from FontAwesome's injected CSS, so it's now defined in App.css. Without it, the hidden labels in the song history and nav were showing.

Left for later

  • TypeScript. It's a natural next step now that the code is split into small hooks, but it would have doubled the size of this PR.
  • HLS. It would help on flaky mobile connections, but it needs to be turned on in AzuraCast first.
  • The page still loads the old Universal Analytics tag (UA-55446531-21). UA has been shut down, so it can probably go, but that's a separate call.

CRA is deprecated and Node 20 is end of life. Netlify now publishes dist/.
REACT_APP_ env vars still work, so no Netlify change is needed for Sentry.
- Watch playback progress and reconnect with backoff when the stream stalls,
  errors or ends, failing over to the other stream after the first retry
- Default to 128kbps when the browser doesn't report connection speed
  (Firefox and Safari were always getting 64kbps and no album art)
- Fade with a Web Audio gain node, which also works on iOS
- Resume a suspended AudioContext instead of playing silence
- Reconnect the now playing SSE and resync from the JSON snapshot
- Time the progress bar from the server's elapsed time and the audio buffer
- Show unique listeners instead of connections
- Add Media Session metadata and controls
- Draw the visualizer outside React state, sharp on HiDPI, and respect
  prefers-reduced-motion
- Replace FontAwesome, react-device-detect, react-page-visibility and store
  with small local equivalents
ubuntu-20.04 runners are retired, so the old workflow wasn't running.
The .sr-only class used to come from FontAwesome's injected CSS, so removing
FontAwesome made the hidden labels in the song history and nav visible.
@ahmaxed
ahmaxed force-pushed the feat/modernize-player branch from 0af9779 to c3d6eb1 Compare November 13, 2026 14:16
Comment thread .nvmrc
@@ -1 +1 @@
20.19.0
24

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might as well bump node to v26 here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider move the dev server to vite from deprecated CRA

2 participants