6.23.0 - Startup progress checkpoints for a determinate loading bar
Drop-in from 6.22.1. One addition, no behaviour change on the playback path: a published startup progress axis for hosts that want a determinate loading bar instead of a spinner.
What a host could see, and where the wait actually was
Six edges were derivable from what the engine already published: the load being dispatched, playbackPhase leaving .idle, the first non-empty audioTracks, isSessionReady, hasFirstFrameReadyForDisplay, the first .playing. Roughly 20% steps, and livable, except for where the edges sit. On a slow origin almost the entire wait falls between two of them, inside the source open, and a bar built on that ladder stands at 20% for ten seconds and then sprints. Requested in #361 by @dlev02, who had shipped exactly that ladder and measured the gap.
The ladder
startupProgress publishes a StartupProgress?: completed of total, a normalized fraction, and a stage naming the work in flight. Nine checkpoints, each recorded by the code that finishes the work it names, so nothing runs on a timer and nothing advances on an estimate.
dispatched -> sourceOpened -> containerOpened -> streamsProbed -> displayPrepared
-> routed -> sessionConstructed -> ready -> presenting
Three of those sit inside the source open, which is the stretch the report is about: the connection coming up and delivering its first bytes, avformat_open_input identifying the container, and the stream-info pass that follows it. A fourth covers the display-criteria handshake, which on a real SDR to HDR switch is seconds long and was equally invisible.
The order is the contract, and it is what removes the need for a per-path total. A path that legitimately skips work records the checkpoint it does reach, and the ones behind it are credited by that alone: a local file has no connection to bring up, the remote-HLS bypass demuxes nothing and runs no panel handshake, an audio session has no picture. So a skipped stretch jumps and nothing ever stalls waiting for a checkpoint its path will never emit.
Three things the shape had to get right
The generation is not loadGeneration. That one counts teardowns, and the engine's own reroutes (an HLS playlist discovered on the loopback path, a carriage case rerouted onto ingest) tear down and re-enter load() underneath one uninterrupted wait. Scoping the axis to it would drop the bar to zero halfway through a load nobody restarted. StartupProgress.generation counts the waits a user actually sat through, and the reroutes mark the continuation explicitly.
The last checkpoint is the picture, not .playing. On the native path state becomes .playing before the item is ready, because automaticallyWaitsToMinimizeStalling handles play-before-ready. A ladder ending there reports a finished startup over a black screen, and a paused mount (autostart: false) never reaches it at all.
An audio session has a picture nowhere, so it completes at readiness rather than waiting forever on a first frame that is not coming.
A load that fails or is stopped simply never reaches the last checkpoint; nothing fakes an ending. Steady republishes are deduped, so a sink on this fires once per real step.
Using it
engine.$startupProgress
.compactMap { $0 }
.sink { p in
bar.progress = Float(p.fraction) // completed / total
label.text = describe(p.stage) // .analyzingStreams, .preparingDisplay, ...
if p.isComplete { hideLoadingUI() }
}generation is there for a host that holds a snapshot across a load(): it can tell stale from current without tracking loads itself.
Verifying it
Every checkpoint also emits one log line, so the ladder can be read off a device trace or an aetherctl run without a host to render it:
[AetherEngine] #361 startup 3/8 streamsProbed (gen 1)
Measured with aetherctl play, one run per path:
| path | checkpoints |
|---|---|
| native loopback, local file | 0,2,3,4,5,6,8 |
| native loopback, origin at 600 kbit/s with 400 ms latency | 0,1,2,3,4,5,6,8 |
| software | 0,2,3,4,5,6,7,8 |
| audio (AVPlayer) | 0,2,3,5,6,7,8 |
| audio (FFmpeg renderer) | 0,1,2,3,5,6,7,8 |
| remote-HLS bypass via the AE#154 reroute | 0,5,6,8, generation unchanged |
Against the slow origin the three open checkpoints land seconds apart, which is the visibility the axis exists for. One skip is a measurement rather than a design: on the native path AVPlayer's layer holds a picture before the item publishes readyToPlay, so ready is usually overtaken by presenting there. It stays in the ladder because the software and audio paths pass it in order, and because on a start where the picture lags readiness it is the only movement in that stretch.