Browsing the Dial Without Killing the Hour
Airshift is personal web radio in the tab. The hour is a queue, a transport, and a DJ that already started talking. I wrote about the architecture and keeping the board live. This is a smaller seam that bit harder than it looked: changing stations while something is On Air.
The cheap click
A preset button looks like a link. On standby it is a link — pick Jazz, the board becomes Jazz. While live, the same click used to mean “tear down the hour and start another.” Queue gone. Generation bumped. Station ID again. OC ReMix into Rock felt like a bug even when it was intentional.
The product rule I wanted: browse is free; commit costs the hour. Listeners should be able to peek at Classical’s crates, flip to Hip-Hop’s dial chrome, and stay on Jazz until they mean it.
Two stations in one board
While On Air there are two names for “the station”:
selected— what the transport is actually playingpreview— what the dial is focused on
The UI reads a single dial:
export function dialStation(preview, selected) {
return preview ?? selected;
}
Freq, name, theme, crates, and the share query follow dial(). The player, queue, and tab title stay on selected. That split is the whole design.
“Something is pending” is not only “preview ≠ selected.” Crate, order, library, and channel can drift from an AirSnapshot captured when the hour started. Same station, different crate — still Tune, still don’t kill audio yet.
export function browsePending(input) {
if (!input.live) return false;
if (input.previewId && input.previewId !== input.selectedId) return true;
// …crate / order / library / channel vs air snapshot
}
On the live path, selectStation sets preview, re-renders the board, updates the URL, and returns. It never calls stop().
Discoverability is half the bug
Once browse was free, the next failure mode was quieter: people did not know how to finish the switch. The big On Air button hides while live (you are already on air). Confirm lived on a small lamp that only said Tune after you had already found the dial.
Two fixes:
- Stations next to the lamp while live — opens the tuner fold and scrolls it into view. The fold label becomes “Stations” instead of “Presets.”
- When browse is pending,
#gocomes back as Tune. Same control you used to start the hour.
.now.live:not(.tune-pending) #go {
display: none;
}
State that only exists in a lamp is state nobody owns. Put the commit on the control people already trust.
Gotchas that came with the split
Share URL follows the dial. While you are previewing Classical, ?s= is Classical even though Jazz is still in the speakers. Track slug stays off the URL until you are not pending, so you do not pin Jazz’s song onto Classical’s link.
Cluster stations must not load into the airing queue from a preview. Calling loadCluster() from the dial focus while Jazz is live would mutate queue / index for the wrong mount. Preview paints channel lists; it does not become the bed.
Tab title stays honest to the air. The board can show Classical. document.title still names what you hear. That asymmetry is correct and a little weird until you expect it.
What it feels like
On Air. Tap Stations. Wander. The hour keeps going. When the dial is where you want it, hit Tune — then the generation flips and the new station ID runs.
Two variables. One button that means commit. A lot less “why did my song die when I was only looking.”
Try it on airshift.fly.dev — go On Air, open Stations, pick something else, then Tune when you mean it.