6.13.0 - A first-frame-ready signal, because readiness never was one
One new published property, and nothing else changes. Drop-in from 6.12.1, no source change on the consumer side.
Added: hasFirstFrameReadyForDisplay, the edge a black cover comes off on
isSessionReady is AVPlayerItem.readyToPlay. AVFoundation reaches it before the layer holds a picture, and it stays true across a seek, so a host that approximates presentation from phase == .playing/.paused && isSessionReady lifts its cover onto black, and a load opened paused never gets a second signal at all. The one signal that could answer the question was internal: nativeHost is not public, and the software path had no equivalent to reach for in the first place.
AetherEngine.hasFirstFrameReadyForDisplay is that signal, folded from AVPlayerLayer.isReadyForDisplay on the native path and AVSampleBufferDisplayLayer.isReadyForDisplay on the software one. The software property is explicitly not KVO-observable; AVFoundation posts AVSampleBufferDisplayLayerReadyForDisplayDidChangeNotification for it. It arrived in tvOS/iOS 17.4 and macOS 14.4, below this package's floor, so older systems fall back to the first frame handed to the renderer, which is one hop earlier than presentation and is documented as such on the property. Audio-only sessions have nothing to display and leave it false.
Both paths, measured headless with aetherctl play:
[NativeAVPlayerHost] #1 layer.isReadyForDisplay=false t+0.00s
[NativeAVPlayerHost] #1 layer.isReadyForDisplay=true t+0.05s
[NativeAVPlayerHost] #1 timeControlStatus=playing t+0.11s
[SWHost] first video frame enqueued: pixfmt=0x34323076 size=640x360 pts=0.040s
[SWHost] layer.isReadyForDisplay=true after 6 frames
It is latched for the load, not mirrored as a level
An item swap costs the layer its picture, and it does so even when the swap is the in-place handover that exists precisely so nobody sees it. On an unattached AVPlayerLayer, with the player already playing:
t+2.030 before replaceCurrentItem: isReadyForDisplay=true
t+2.069 KVO isReadyForDisplay=false
t+2.106 KVO isReadyForDisplay=true
Roughly 40 ms of no picture, per swap. Published as a level, that would make every host re-cover the seams the engine goes out of its way to hide: the media fallback, the wired-HDMI AirPlay master swap, the #93 recovery reload, the AE#158 PiP handover. A falling edge would also be ambiguous in the way SeekEvent was introduced to fix, since nothing in a level says why it fell.
So the flag rises once per load and holds. The seams above reuse the running host, call host.load() directly and keep the latch. A rebuild that goes back through load(), including reloadAtCurrentPosition(), resets it: there the item is genuinely gone and its first frame has to be reached again. stop() resets it too.
It claims readiness, not visibility
Both layers reach isReadyForDisplay while in no view hierarchy at all. The software run above is the same run that also logs:
[SWHost] 54 frames decoded into a display layer that is in no view hierarchy:
the host never bound a render surface (AetherEngine.bind(view:) / AetherPlayerSurface)
That is the #298 case, and a property named "presented" would have been a lie for exactly the hosts that report it. What this says is that the pipeline has a first frame ready for display. A host rendering through bind(view:) sees it on screen at that moment; an AVPlayerViewController host presents through AVKit's own layer a frame or so later.
Seeks are a different question
A seek keeps the previous frame on screen, so the layer never stops being ready for display and this flag cannot answer "has the scrub landed on screen". seekEvents already does: every .began is terminated by .landed(renderedTime:), .stalled or .superseded under the seek's own id.
Implementation note for anyone reading the fold
Every call site wires its host sinks before it loads the host, so the value @Published replays on subscribe is, on a reused native host, still the outgoing item's picture. Taking it would latch this load's flag on the previous load's frame. The fold drops that replayed value, and the test that pins the case fails without the guard.
aetherctl play prints the edge as FIRSTFRAME hasFirstFrameReadyForDisplay=<v> t+<s> and carries rfd=y/n on every telemetry tick, so the signal is capturable where the report came from.
Verification
1560 tests in 233 suites, -strict-concurrency=complete clean, tvOS Simulator and iOS Simulator builds green, play and play --sw sessions on local fixtures.