Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ the public-API contract.

- `LoadOptions.httpRequestAuthorization` now covers direct play. The byte-range reader asks the resolver for the source URL before every range, reconnect, probe and seek, so a rotated access token reaches the next request instead of the session sending the headers it opened with until the host reloads the player. A 401 retries once at the same byte offset when the resolver returns a changed `Authorization`. A resolver that throws or exceeds its 10 s bound fails the request before it is sent, so an open fails as `authorizationUnavailable`. Unchanged credentials, a second 401, or a failed refresh end the read after the 401, and fail an open with that status. Neither runs the reconnect ladder. The resolver is asked about the source only, so every header it returns counts as a credential: none reaches a cross-origin redirect target or a target pinned from one, which get the non-credential static headers instead. Live ingest, remote disc images and audio-only sources that AVPlayer decodes natively keep static headers. Resolvers run on an engine-owned serial executor, off Swift's cooperative pool, so demuxer opens that occupy every pool thread while they wait cannot starve the resolver they wait for.

- `audioSessionReleaseGate` lets a host veto the scheduled `deactivatesAudioSessionOnStop` release just before `setActive(false)` runs. The release runs after `stop()` returns and `setActive(false)` alone takes about half a second on an Atmos passthrough route, but only a `load()` on the same engine cancelled it, so a host that opened a new player in that window could have the new player's session released. Returning `false` skips the release and logs it; with no gate set the release runs as before.

- `LoadOptions.httpRequestAuthorization` accepts an async `HTTPRequestAuthorization` resolver for native HLS. The engine resolves headers before requests and redirects, and retries a rejected request once when the bearer changes, preserving the active player item across token rotation.

### Fixed
Expand Down
25 changes: 25 additions & 0 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,16 @@ public final class AetherEngine: ObservableObject {
/// software or audio-only load is still activating releases the session after that activation (AE#538).
public var deactivatesAudioSessionOnStop: Bool = false

/// Last word on a scheduled `deactivatesAudioSessionOnStop` release. Default `nil`, which releases as before.
///
/// The release runs off the main actor after `stop()` returns, and `setActive(false)` alone takes about
/// half a second on an Atmos passthrough route. The engine's own guard only covers a `load()` on the same
/// engine, so a host that runs more than one engine (a new player opened while the old one is still
/// releasing) answers here whether this engine still owns the session. Returning `false` skips `setActive(false)` and logs the skip. Read when `stop()`
/// schedules the release and called off the main actor right before it, after any renderer activation
/// queued ahead of it, so keep it cheap and thread-safe.
public var audioSessionReleaseGate: (@Sendable () -> Bool)?

@Published public internal(set) var duration: Double = 0

/// Forwarder; see `clock.progress`.
Expand Down Expand Up @@ -6570,6 +6580,12 @@ public final class AetherEngine: ObservableObject {
finalTeardown && !keepNativeHost && hostOptedIn
}

/// Whether a scheduled release may still run once its turn comes: no `audioSessionReleaseGate`, or one
/// that answers `true`.
nonisolated static func audioSessionReleaseGateAllows(_ gate: (@Sendable () -> Bool)?) -> Bool {
gate?() ?? true
}

#if os(iOS) || os(tvOS)
/// Counterpart to the activations the engine takes part in (#215). The native path never activates the
/// session itself (AVKit does it per playback, #24); the software and audio renderer paths do, in
Expand Down Expand Up @@ -6624,11 +6640,20 @@ public final class AetherEngine: ObservableObject {
/// the meantime bumps it again and the pending deactivation drops rather than releasing the session
/// out from under the new item. `stopInternal` also cancels a pending task before scheduling a new one.
/// The release queues behind a renderer activation still in flight (AE#538), see `enqueueAudioSessionTransition`.
/// Another engine's `load()` is out of reach of that guard, so the host's `audioSessionReleaseGate` gets the
/// last word.
private func scheduleAudioSessionDeactivation() {
let generation = loadGeneration
let gate = audioSessionReleaseGate
audioSessionDeactivationTask = enqueueAudioSessionTransition { [weak self] in
guard let self else { return }
guard !Task.isCancelled, await self.loadGeneration == generation else { return }
guard AetherEngine.audioSessionReleaseGateAllows(gate) else {
EngineLog.emit(
"[AetherEngine] AVAudioSession release skipped: audioSessionReleaseGate returned false",
category: .engine)
return
}
AetherEngine.deactivateSharedAudioSession()
}
}
Expand Down
20 changes: 20 additions & 0 deletions Tests/AetherEngineTests/AudioSessionTeardownPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,25 @@ struct AudioSessionTeardownPolicyTests {
func optInDefaultsOff() throws {
let engine = try AetherEngine()
#expect(engine.deactivatesAudioSessionOnStop == false)
#expect(engine.audioSessionReleaseGate == nil)
}

/// The scheduled release runs after `stop()` returns (its `setActive(false)` alone takes ~0.5 s on an
/// Atmos passthrough route), and its own guard only sees this engine's loads. A host that opens a
/// second player in that window answers through `audioSessionReleaseGate`; without one the release
/// behaves exactly as before.
@Test("without a release gate a scheduled release still deactivates")
func noGateDeactivates() {
#expect(AetherEngine.audioSessionReleaseGateAllows(nil))
}

@Test("a release gate that answers false skips the deactivation")
func refusingGateSkips() {
#expect(!AetherEngine.audioSessionReleaseGateAllows { false })
}

@Test("a release gate that answers true deactivates")
func allowingGateDeactivates() {
#expect(AetherEngine.audioSessionReleaseGateAllows { true })
}
}
1 change: 1 addition & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,7 @@ say so on the tracker rather than working around it.
| `audioNowPlayingSession`, `setAudioNowPlayingInfo(_:)` | The same pair for the audio-only path, which owns its session unconditionally (there is no AVKit fork there). Pass an empty dictionary to clear. |
| `setExternalMetadata(_:)` | AVKit's on-screen info pane on the video path. Safe before `load()`; replayed at host creation. |
| `deactivatesAudioSessionOnStop` | Off by default. The engine declares the audio-session category at init and never activates it on the native path, because AVKit activates per playback and that is what lets tvOS negotiate the HDMI route (#24), so it never deactivates it either. Set true only when the app owns the session outright; the engine then releases it on a genuine final teardown, meaning `stop()` and never a reload, handoff or live retune. |
| `audioSessionReleaseGate` | Optional `@Sendable () -> Bool`, `nil` by default. The release above runs off the main actor after `stop()` returns, `setActive(false)` alone takes about half a second on an Atmos passthrough route, and the engine only cancels the release for a `load()` on the same engine. A host that runs several engines, or opens a new player while the old one is still releasing, sets this to answer whether this engine still owns the session; `false` skips `setActive(false)` and logs the skip. Read when `stop()` schedules the release and called off the main actor just before it, after any renderer activation queued ahead, so keep it cheap and thread-safe. |

## Stills and thumbnails

Expand Down