Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ the public-API contract.

## [Unreleased]

_Nothing yet._
### Added

- **`LoadOptions.displayCriteriaOwner` (`.engine` / `.avKit` / `.host`), AE#842.** `suppressDisplayCriteria`
means "AVKit writes the criteria", and every branch behind it waits for that write: a host that keeps
the panel in its system format (or writes rate-only criteria itself) had no correct setting and paid up
to a second of play-gate wait per HDR or Dolby Vision title for a write that never came, with the
engine's panel readout switched off. `.host` writes nothing, takes the brief budget, reads the panel
and still waits out a switch it observes. `suppressDisplayCriteria = true` keeps meaning `.avKit`.

## [7.40.1] - 2026-10-18

Expand Down
8 changes: 4 additions & 4 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -719,12 +719,12 @@ extension AetherEngine {
/// bypass. The loopback path applies criteria before item load from its libav probe; this path has no
/// probe, so it applies late, once AVPlayer's parsed video-track format resolves the range. Without the
/// panel switch a bare AVPlayerLayer presents no HDR video (audio-only, black; the reporter's symptom).
/// SDR needs no switch, and a sole-writer host (`suppressDisplayCriteria`) is left untouched. No-op off
/// SDR needs no switch, and criteria owned by AVKit or the host are left untouched (AE#842). No-op off
/// tvOS (apply() returns .applied there) and where Match Content is off (apply() skips the write).
@MainActor
private func applyRemoteHLSDisplayCriteria(format: VideoFormat, options: LoadOptions) {
guard RemoteHLSFormatDetection.shouldApplyDisplayCriteria(
format: format, suppressDisplayCriteria: options.suppressDisplayCriteria) else { return }
format: format, engineWritesCriteria: options.engineWritesDisplayCriteria) else { return }
_ = displayCriteria.apply(
format: format,
frameRate: nativeHost?.detectedVideoFrameRate,
Expand Down Expand Up @@ -2617,7 +2617,7 @@ extension AetherEngine {
// #339: the only write this reload can still produce is a sole-writer host's re-write on the
// swapped item, which happens inside loadNative. Arm before it, or the gate below is again
// reading a flag for a switch whose notifications it was not registered for.
if loadedOptions.suppressDisplayCriteria { displayCriteria.armSwitchObservation() }
if !loadedOptions.engineWritesDisplayCriteria { displayCriteria.armSwitchObservation() }
let reloadRoutingPanelHDR = Self.reloadRoutesAsHDRPanel(
hostAsserts: loadedOptions.panelIsInHDRMode,
criteriaReadoutAtLoad: sessionPanelHDRReadout,
Expand Down Expand Up @@ -2693,7 +2693,7 @@ extension AetherEngine {
await displayCriteria.waitForSwitch(
startGrace: Self.playGateGrace(
criteriaUnchanged: false,
engineIsCriteriaWriter: !loadedOptions.suppressDisplayCriteria,
criteriaWrittenBeforeLoad: !loadedOptions.awaitsInboundCriteriaWrite,
formatKnown: true,
effectiveFormat: videoFormat,
noWriterExpected: loadedOptions.sharedOutputRole == .secondary
Expand Down
14 changes: 9 additions & 5 deletions Sources/AetherEngine/AetherEngine+Probe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -545,15 +545,15 @@ extension AetherEngine {
enum LoadDisplayCriteriaAction {
/// Engine-writer video path: apply() overwrites the preserved criteria in place, single handshake.
case applyFresh
/// No engine apply() this session (audio-only, or an AVKit-sole-writer host): clear a criteria a
/// No engine apply() this session (audio-only, or criteria owned by AVKit or the host): clear a criteria a
/// previous session left applied, so it can't keep the panel in DV/HDR under music playback or
/// fight AVKit's own write. `DisplayCriteriaController.reset()` stays didApply-gated, so this is
/// a no-op for hosts that never route criteria through the engine.
case clearStale
}

nonisolated static func loadDisplayCriteriaAction(suppressDisplayCriteria: Bool, audioOnlyPath: Bool) -> LoadDisplayCriteriaAction {
(suppressDisplayCriteria || audioOnlyPath) ? .clearStale : .applyFresh
nonisolated static func loadDisplayCriteriaAction(engineWritesCriteria: Bool, audioOnlyPath: Bool) -> LoadDisplayCriteriaAction {
(!engineWritesCriteria || audioOnlyPath) ? .clearStale : .applyFresh
}

/// #274: how long the post-load play-gate blind-polls for a panel switch to start.
Expand All @@ -571,21 +571,24 @@ extension AetherEngine {
/// rate-only, and the engine already declines to gate its own rate-only writes (`apply()` returns
/// `.applied` with no pre-flight wait) because those switches are sub-second.
///
/// AE#842: a host-owned session counts as written-before-load too. The host wrote its criteria (or none)
/// before calling `load()`, so like the engine's own write there is nothing inbound to blind-poll for.
///
/// `formatKnown` is false when the open-time probe failed: the real range is then unknown and a DV write
/// may still be inbound, so a suppressed host keeps the full budget.
///
/// `noWriterExpected` is a `.secondary` load (Sodalite#175): nobody writes criteria for it, so there is
/// no inbound switch to wait for.
nonisolated static func playGateGrace(
criteriaUnchanged: Bool,
engineIsCriteriaWriter: Bool,
criteriaWrittenBeforeLoad: Bool,
formatKnown: Bool,
effectiveFormat: VideoFormat,
noWriterExpected: Bool = false
) -> DisplayCriteriaController.StartGrace {
// #133: the criteria were already active, nothing was written, nothing can settle.
if criteriaUnchanged || noWriterExpected { return .skip }
if engineIsCriteriaWriter { return .brief }
if criteriaWrittenBeforeLoad { return .brief }
guard formatKnown else { return .full }
return effectiveFormat == .sdr ? .brief : .full
}
Expand All @@ -595,6 +598,7 @@ extension AetherEngine {
guard options.sharedOutputRole == .secondary else { return options }
var adjusted = options
adjusted.suppressDisplayCriteria = true
adjusted.displayCriteriaOwner = .engine
return adjusted
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/AetherEngine/AetherEngine+ReloadWithOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ enum SessionOptionCorrection {
/// right for a tuning lever and wrong for an identity one, so the choice has to be made
/// deliberately. Update this list and, if the field names the session, `loadIdentityFields`.
static let knownFields: [String] = [
"omitCriteriaColorExtensions", "suppressDisplayCriteria", "httpHeaders", "nativeVideoFrameDiagnostics", "nativeVideoFrameRecovery", "hostRetriesExhaustedLiveReplay",
"omitCriteriaColorExtensions", "suppressDisplayCriteria", "displayCriteriaOwner", "httpHeaders", "nativeVideoFrameDiagnostics", "nativeVideoFrameRecovery", "hostRetriesExhaustedLiveReplay",
"keepDvh1TagWithoutDV", "forceDolbyVisionOnNonDVDisplay", "dolbyVisionHandling", "preferDualLayerDolbyVision", "matchContentEnabled",
"panelIsInHDRMode", "attemptsHDRMasterOnUnprovenPanel", "panelPresentsDolbyVision",
"audioBridgeMode", "objectAudioRendering", "isLive", "audioOnly",
Expand Down
12 changes: 6 additions & 6 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4797,7 +4797,7 @@ public final class AetherEngine: ObservableObject {
// The load seam preserves display criteria (#128 follow-up); an audio-only session never calls
// apply(), so clear a criteria the previous video session left applied. The engine is a
// process-wide singleton; without this, music playback keeps the panel in DV/HDR.
if Self.loadDisplayCriteriaAction(suppressDisplayCriteria: options.suppressDisplayCriteria, audioOnlyPath: true) == .clearStale {
if Self.loadDisplayCriteriaAction(engineWritesCriteria: options.engineWritesDisplayCriteria, audioOnlyPath: true) == .clearStale {
resetDisplayCriteriaRespectingOthers()
}
// Read codec before closing the probe; custom sources always use FFmpeg (AVPlayer can't consume a custom demuxer).
Expand Down Expand Up @@ -4890,7 +4890,7 @@ public final class AetherEngine: ObservableObject {
// starts a switch, so the post-load play-gate waitForSwitch() below has nothing to settle and would
// otherwise burn its full ~3s cap on unobservable-DV panels. Skip it in exactly that case.
var criteriaUnchanged = false
switch Self.loadDisplayCriteriaAction(suppressDisplayCriteria: options.suppressDisplayCriteria, audioOnlyPath: false) {
switch Self.loadDisplayCriteriaAction(engineWritesCriteria: options.engineWritesDisplayCriteria, audioOnlyPath: false) {
case .applyFresh:
let codecTag: FourCharCode? = detectedDVProfile ? 0x64766831 : nil
switch displayCriteria.apply(
Expand Down Expand Up @@ -4927,7 +4927,7 @@ public final class AetherEngine: ObservableObject {
// load rather than when the play gate opens. After reset(), so a switch back to the default
// mode is not recorded as this session's. Audio-only loads reach clearStale too and have no
// panel handshake to observe.
if options.suppressDisplayCriteria { displayCriteria.armSwitchObservation() }
if !options.engineWritesDisplayCriteria { displayCriteria.armSwitchObservation() }
}

// 2.5. Post-handshake panel-mode snapshot.
Expand All @@ -4947,7 +4947,7 @@ public final class AetherEngine: ObservableObject {
// so a panel parked in HDR never proves itself and one tvOS 27 box stopped answering at all;
// a host that knows better says so, and a wrong claim costs the -11848 fallback, not the item.
var criteriaPanelReadout: Bool? =
options.suppressDisplayCriteria ? nil : displayCriteria.currentPanelIsHDR()
options.awaitsInboundCriteriaWrite ? nil : displayCriteria.currentPanelIsHDR()
var panelHDRAfterHandshake = Self.sessionPanelPresentsHDR(
hostAsserts: options.panelIsInHDRMode, criteriaReadout: criteriaPanelReadout)
// AE#459: the ROUTE may assume more than the LABEL may claim. `panelHDRAfterHandshake` stays the
Expand Down Expand Up @@ -4978,7 +4978,7 @@ public final class AetherEngine: ObservableObject {
}
try checkLoadCurrent(gen)
}
criteriaPanelReadout = options.suppressDisplayCriteria ? nil : displayCriteria.currentPanelIsHDR()
criteriaPanelReadout = options.awaitsInboundCriteriaWrite ? nil : displayCriteria.currentPanelIsHDR()
panelHDRAfterHandshake = Self.sessionPanelPresentsHDR(
hostAsserts: options.panelIsInHDRMode, criteriaReadout: criteriaPanelReadout)
routingPanelHDR = Self.sessionRoutesAsHDRPanel(
Expand Down Expand Up @@ -5495,7 +5495,7 @@ public final class AetherEngine: ObservableObject {
await displayCriteria.waitForSwitch(
startGrace: Self.playGateGrace(
criteriaUnchanged: criteriaUnchanged,
engineIsCriteriaWriter: !options.suppressDisplayCriteria,
criteriaWrittenBeforeLoad: !options.awaitsInboundCriteriaWrite,
formatKnown: probeOpened,
effectiveFormat: effectiveFormat,
noWriterExpected: options.sharedOutputRole == .secondary
Expand Down
30 changes: 30 additions & 0 deletions Sources/AetherEngine/Display/DisplayCriteriaOwner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/// Who writes `AVDisplayManager.preferredDisplayCriteria` for a load (AE#842). See `LoadOptions.displayCriteriaOwner`.
public enum DisplayCriteriaOwner: String, Sendable, Equatable {
/// The engine writes range and rate before the item loads and waits for the switch. The default.
case engine
/// AVKit writes them from the `AVPlayerItem` (`AVPlayerViewController.appliesPreferredDisplayCriteriaAutomatically`),
/// somewhere inside the load. The engine holds play() up to a second for that write on HDR and Dolby Vision
/// content, and does not read the panel, because a readout before AVKit's write would be stale.
/// `suppressDisplayCriteria = true` is the older spelling of this case.
case avKit
/// The host decides before `load()`: it writes criteria of its own (rate-only, say), or none, so the panel
/// stays in the output format the user chose in tvOS Settings. The engine never writes, waits for no
/// write it was not told about, reads the panel as on its own path, and still waits out a switch it sees
/// running when the play gate opens.
case host
}

extension LoadOptions {
/// The owner this load runs under. An explicit `displayCriteriaOwner` other than `.engine` wins;
/// otherwise `suppressDisplayCriteria` keeps its historical meaning, AVKit.
var effectiveDisplayCriteriaOwner: DisplayCriteriaOwner {
if displayCriteriaOwner != .engine { return displayCriteriaOwner }
return suppressDisplayCriteria ? .avKit : .engine
}

var engineWritesDisplayCriteria: Bool { effectiveDisplayCriteriaOwner == .engine }

/// Whether a criteria write may still land inside the load, from a writer whose timing the engine does
/// not control. Only AVKit's does; the engine's and the host's happened before the item was built.
var awaitsInboundCriteriaWrite: Bool { effectiveDisplayCriteriaOwner == .avKit }
}
8 changes: 4 additions & 4 deletions Sources/AetherEngine/Native/RemoteHLSFormatDetection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ enum RemoteHLSFormatDetection {

/// Whether the nativeRemoteHLS bypass should program `preferredDisplayCriteria` for a detected format.
/// Only an HDR range needs the panel switch that lets AVPlayer present HDR at all; SDR is presented in
/// any panel mode, and a sole-writer host (`suppressDisplayCriteria`) is left untouched so the engine
/// and the host never fight over the criteria. Pure so it is unit-testable.
static func shouldApplyDisplayCriteria(format: VideoFormat, suppressDisplayCriteria: Bool) -> Bool {
format != .sdr && !suppressDisplayCriteria
/// any panel mode, and criteria owned by AVKit or the host are left untouched so the engine and the
/// other writer never fight over them. Pure so it is unit-testable.
static func shouldApplyDisplayCriteria(format: VideoFormat, engineWritesCriteria: Bool) -> Bool {
format != .sdr && engineWritesCriteria
}

/// Video sample types the bypass can name, mapped to the libavcodec spelling the probe path publishes.
Expand Down
10 changes: 9 additions & 1 deletion Sources/AetherEngine/PlayerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,14 @@ public enum DolbyVisionHandling: String, Sendable, Equatable, CaseIterable {
public struct LoadOptions: Sendable, Equatable {
/// Diagnostic lever: omit BT.2020 / transfer / YCbCr matrix from AVDisplayCriteria so AVPlayer re-reads color from the bitstream. Default off.
public var omitCriteriaColorExtensions: Bool
/// Skip display-criteria handshake entirely. For previews and `aetherctl` where no panel exists. Default off.
/// The engine writes no display criteria because AVKit does: `true` is `DisplayCriteriaOwner.avKit`, and the
/// engine holds play() up to a second for AVKit's write on HDR and Dolby Vision content. Also the setting for
/// previews and `aetherctl`, where no panel exists. A host that keeps the panel in its system format, or
/// writes criteria itself, wants `displayCriteriaOwner = .host` instead. Default off.
public var suppressDisplayCriteria: Bool
/// Who writes `preferredDisplayCriteria` for this load (AE#842). `.engine` (default) defers to
/// `suppressDisplayCriteria`; `.avKit` and `.host` win over it. See `DisplayCriteriaOwner`.
public var displayCriteriaOwner: DisplayCriteriaOwner = .engine
/// Extra HTTP headers for HEAD probe, Range chunks, side-demuxer fetches. On the loopback paths they are NOT forwarded to AVPlayer (it hits the local server); on `nativeRemoteHLS` they ride into the AVURLAsset so header-enforcing origins (IPTV Referer / User-Agent / Authorization) work (#119). Forwarded to `selectSidecarSubtitle` by default; pass explicit headers to override (#32). Default empty.
public var httpHeaders: [String: String]

Expand Down Expand Up @@ -984,6 +990,7 @@ public struct LoadOptions: Sendable, Equatable {
public init(
omitCriteriaColorExtensions: Bool = false,
suppressDisplayCriteria: Bool = false,
displayCriteriaOwner: DisplayCriteriaOwner = .engine,
httpHeaders: [String: String] = [:],
keepDvh1TagWithoutDV: Bool = false,
forceDolbyVisionOnNonDVDisplay: Bool = false,
Expand Down Expand Up @@ -1038,6 +1045,7 @@ public struct LoadOptions: Sendable, Equatable {
) {
self.omitCriteriaColorExtensions = omitCriteriaColorExtensions
self.suppressDisplayCriteria = suppressDisplayCriteria
self.displayCriteriaOwner = displayCriteriaOwner
self.httpHeaders = httpHeaders
self.keepDvh1TagWithoutDV = keepDvh1TagWithoutDV
self.forceDolbyVisionOnNonDVDisplay = forceDolbyVisionOnNonDVDisplay
Expand Down
8 changes: 4 additions & 4 deletions Tests/AetherEngineTests/DisplayCriteriaLoadActionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@ struct DisplayCriteriaLoadActionTests {

@Test("Video load with the engine as criteria writer applies fresh criteria in place")
func videoEngineWriterAppliesFresh() {
#expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: false, audioOnlyPath: false) == .applyFresh)
#expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: true, audioOnlyPath: false) == .applyFresh)
}

@Test("Suppressed (AVKit-sole-writer) load clears a stale engine criteria instead of applying")
func suppressedClearsStale() {
// A previous non-suppressed session may have left its criteria applied across the preserved
// load seam; leaving it in place alongside AVKit's own write recreates the dual-writer fight.
#expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: true, audioOnlyPath: false) == .clearStale)
#expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: false, audioOnlyPath: false) == .clearStale)
}

@Test("Audio-only load clears a stale criteria so music cannot inherit the video session's panel mode")
func audioOnlyClearsStale() {
#expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: false, audioOnlyPath: true) == .clearStale)
#expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: true, audioOnlyPath: true) == .clearStale)
}

@Test("Suppressed audio-only load also clears")
func suppressedAudioOnlyClearsStale() {
#expect(AetherEngine.loadDisplayCriteriaAction(suppressDisplayCriteria: true, audioOnlyPath: true) == .clearStale)
#expect(AetherEngine.loadDisplayCriteriaAction(engineWritesCriteria: false, audioOnlyPath: true) == .clearStale)
}
}
Loading
Loading