Skip to content

6.22.0 - System caption requests reach the host

Choose a tag to compare

@superuser404notfound superuser404notfound released this 13 Aug 04:36
· 733 commits to main since this release

Drop-in from 6.21.1. Captions the system switches on by itself no longer draw over a host's own subtitles, and they reach the host as a request instead of being swallowed. Plus a teletext page that can change while a channel plays.

A caption box nobody asked for

The native WebVTT legible rendition exists for PiP, AirPlay and external screens, where a host's on-frame overlay cannot draw. In fullscreen it has to stay deselected, and forceNativeLegibleDeselectedUntilHostSelects() pinned it for the first ~2 s of a session. That covers AVKit's ready-time auto-select and nothing after it.

iOS 26 selects it later. Settings > Accessibility > Subtitles & Captioning has three automatic-caption toggles (show when muted, on skip back, on a language mismatch), each of them a user action minutes into a session, and with the burst spent nothing held them back: AVKit rendered the rendition over the frame as an empty grey caption box, its text invisible under the host's transparent style rules.

The burst now hands over to a media-selection observer that stays armed for the item's whole life and re-asserts select(nil) on every selection the engine did not make. NativeLegibleDeselectPin bounds it, because a pin that loses is worse than a pin that stops: several re-asserts inside one second are a fight the engine cannot win, so it stands down and says so.

[AetherEngine] Sodalite#65: legible option "Deutsch" was selected from outside the engine (iOS automatic captions); deselected again, publishing the request (lang=de)

The request reaches the host

None of the three toggles has a read API. MACaptionAppearanceGetDisplayType answers forcedOnly, automatic or alwaysOn and knows nothing about them, and AVKit's media-selection delegate is tvOS-only. What the system does have is an effect, and that effect is the selection above.

systemCaptionRequest publishes it:

engine.systemCaptionRequest
    .receive(on: DispatchQueue.main)
    .sink { request in showSubtitles(matching: request.language) }

The payload is the option's language tag rather than a track id: rendition ordinals are matched by language rank and not positionally, so the language is the value that survives the round trip. The engine still deselects, so a host that ignores the publisher behaves exactly as before, and a host that acts on it renders the captions in its own presentation. Note that the signal does not say which of the three toggles fired; a host that answers only one of them (the mute case, say) has to tell them apart itself, for instance by the output volume at the moment of the request, or by whether it just seeked backwards.

The teletext page can change while a channel plays

setTeletextPage(_:) sets the caption page on a running channel. The page used to reach EmbeddedSubtitleDecoder only at construction, so it was fixed for the life of a selection: a channel whose caption page libzvbi does not flag as a subtitle page could only be corrected by leaving the channel, changing a setting and coming back. It travels with the decoder rebuild the drain path already performs, and only the channels actually showing a teletext track are re-decoded. teletextPage reads the page in force, and the value lands in the session's load options, so the internal reopens (audio switch, background reload) replay it rather than falling back to the load-time page.

From the CLI, aetherctl play --teletext-page N fixes the page at load and --switch-teletext-page <page|auto>[@ms] changes it on the playing channel.

Also

  • aetherctl play names the audio stream at load on the custom-IO path, so a CLI repro of an audio-selection defect starts from the same state the host does.