OnAir turns "click a game" into a live HLS stream that stays up. It collects candidate streams from pluggable source adapters, probes and ranks them, plays the best one, and when that stream stalls, errors, expires or freezes, it fails over to the next candidate without the picture ever going black. The demo and every capture here run against its built-in fixture source: two local streams that can be broken on command.

Fig. 1 A game is opened and plays; a stall is injected, the picture holds its last frame, and playback fails over to the second stream without going black. The drawer reports one recovered failure.Recorded from the real app, one take, against its built-in fixture source: two local HLS streams that can be broken on command, before the top bar was rebuilt and the Guide added.

Never black

Two video elements, one visible and one staging. A replacement stream loads out of sight and becomes visible on its first decoded frame. The current picture is torn down only once something better is on screen. A browser test tier runs a real decoder through the swap to prove it: the visible element never drops below the have-current-data ready state, and position only ever moves forward.

Three detectors, because each failure hides from the other two

  • A stall state machine catches a starved buffer. Two states, watching and stalled; progress resets the timer.
  • A fatal-error classifier tells an expired token, which means re-fetching the URL and staying, apart from a dead source, which means moving on. The same status code means different things before and after the first frame.
  • An off-air check catches a stream that returns success on everything while its live edge has stopped moving. The player library sees nothing wrong with that. It fetches the playlist twice, 2.5 seconds apart, every twenty seconds. An early version read the master playlist, which has no media sequence, so every healthy stream looked frozen and failed over every twenty to sixty seconds; it now follows the master to its media playlist, and a sequence it cannot read counts as unknown, never frozen.

An escalation ladder, not a retry loop

Next candidate
the next-ranked probed stream
One fresh extraction
ask the adapters again, once
The source’s own embedded player
verified by watching its time advance
All sources failed
an honest screen, not a spinner
Fig. 2 What happens after a stream fails. Concurrent triggers from the renderer and the main process collapse into one switch.Drawn from docs/FAILOVER.md and src/main/playback/failover-session.ts in the repository.

Continuity

Sources run 45 to 100 seconds apart. When a stream carries wall-clock timestamps, the replacement seeks to the same instant in the broadcast instead of the live edge, so a failover does not skip or repeat play.

Click to play

URL cache
fresh under three minutes is used as is; three to eight minutes is head-validated; otherwise extract again
Pick adapters
those that support the league and are not broken or blocked
Gather candidates concurrently
a two-second grace period once one adapter yields
Match to the game
drop anything under 0.5
Probe the playlists
Score
reliability 0.50, stability 0.20, quality 0.15, startup 0.10, confidence 0.05, times health
Stage, then swap on the first frame
Fig. 3 From a click to a first frame. Candidates are gathered concurrently, matched to the game, probed, and scored before anything is loaded.Drawn from docs/ARCHITECTURE.md and src/main/engine in the repository.

The interface

The home screen is built from ESPN's public scoreboard for six leagues, the NFL, NBA, MLB, NHL, college football and college basketball: team logos, colors and records, live scores, the game clock, the network and the venue, refreshed every sixty seconds while games are on. A live game gets a full-width hero in its teams' colors, with rows of upcoming games under it. The player is full-bleed video with controls that hide themselves and a details drawer: the scoreboard, the current stream's source, quality and distance behind live, the ranked candidates, and how many failures it has recovered from.

OnAir's home screen: a full-width hero for Rams at Broncos, live, 16 to 8 in the third quarter on NBC, with a Watch live button, a Live now row and a Coming up row of games in team colors
Fig. 4 Home during a live NFL game: the hero and the rows come from ESPN's scoreboard, in each team's colors.Screenshot of the app, 27 September 2026, running against its fixture source, before the top bar was rebuilt and the Guide added.
OnAir diagnostics: the Dev Fixture source healthy at 100% confidence, and two recent events, a stream failed and a source switch, both with reason stall
Fig. 5 Diagnostics after the failover in Fig. 1: source health and confidence, and the two events the injected stall produced.Screenshot of the app running against its fixture source, taken right after the recording, before the top bar was rebuilt and the Guide added.

Guide and live channels

A Guide screen, added on 28 September 2026, puts always-on channels beside the games. A source adapter can optionally list the channels it carries; a discovery pass on startup and every 30 minutes collects those listings and folds each source's spelling into one canonical channel, so the same channel from several sources is one row. The guide's programs come from ESPN's games, already in the database, and TVmaze's US schedule for today and tomorrow, shown from about an hour behind to a day ahead. A channel is a watch target exactly like a game: it goes through the same probe, ranking and failover, with its match confidence fixed at 1.0 because the link already names the channel. The fixture source has no channels, so none of this is captured here.

The last change on main fixed a starved extraction. Only four headless pages run at once, and sources that didn't carry the game held them for about 12 seconds each, so the sources that did got a page at about 21 seconds and missed the 30-second extraction timeout. An adapter now stops as soon as no listing link names the game, and adapters are dispatched by priority: 0.7 × their startup success rate (a neutral 0.5 with no history) plus 0.3 × their confidence weight.

Status

Works end to end against the fixture source; the embedded-player rung is covered by unit tests only. macOS only, installed locally with one command as an ad-hoc arm64 build, not signed or notarized for distribution. 738 tests across 39 suites plus seven real-decoder browser tests, and CI runs typecheck, lint, tests, build and the browser tier on macOS for every pull request.

At a glance
Detectors
3
Escalation
4 rungs
Continuity
program-date-time
Scores
ESPN, 60 s poll
Leagues
NFL · NBA · MLB · NHL · CFB · CBB
Tests
738 + 7 real-decoder