From 0a62d7c46de923b8e58c81b5e3a1c287ea24d5c3 Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:14:18 +1000 Subject: [PATCH 1/2] fix(player): preserve Aether state across episode handoff --- .../Tests/AetherPlaybackBoundaryTests.swift | 25 +++++++++++ .../Player/AetherPlaybackController.swift | 41 +++++++++++++------ .../Screens/Player/PlayerViewModel.swift | 13 ++++-- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/iosApp/Tests/AetherPlaybackBoundaryTests.swift b/iosApp/Tests/AetherPlaybackBoundaryTests.swift index 94043486..3196e622 100644 --- a/iosApp/Tests/AetherPlaybackBoundaryTests.swift +++ b/iosApp/Tests/AetherPlaybackBoundaryTests.swift @@ -979,6 +979,31 @@ final class AetherPlaybackBoundaryTests: XCTestCase { controller.stop() } + func testReplacementPreparationInvalidatesOutgoingLoadAndAllowsSuccessorEpoch() throws { + let controller = try AetherPlaybackController() + defer { controller.stop() } + let spec = try AetherLoadSpec( + directURL: URL(string: "https://dev.example.test/api/v1/stream/session")!, + headers: [:], + startPosition: 0, + audioOnly: false + ) + + let outgoingEpoch = controller.beginLoad(spec) + XCTAssertEqual(controller.activeLoadEpoch, outgoingEpoch) + XCTAssertNotNil(controller.activeSpec) + + controller.prepareForReplacement() + + XCTAssertNil(controller.activeLoadEpoch) + XCTAssertNil(controller.activeSpec) + XCTAssertEqual(controller.engine.state, .idle) + + let successorEpoch = controller.beginLoad(spec) + XCTAssertNotEqual(successorEpoch, outgoingEpoch) + XCTAssertEqual(controller.activeLoadEpoch, successorEpoch) + } + /// Opt-in shared-dev proof for the complete server -> StreamRequest -> /// Aether boundary. The fixture stays outside the repository because it /// contains a short-lived bearer credential. Normal test runs skip this; diff --git a/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift b/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift index 11024de6..5f160f2b 100644 --- a/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift +++ b/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift @@ -283,6 +283,19 @@ final class AetherPlaybackController { func dispose() { stop() } + /// Stops the outgoing item while keeping Aether's cross-item handoff + /// state alive for the replacement load. In particular, tvOS must not + /// reset the panel to its default display criteria between consecutive + /// episodes, and the shared audio session must not be released while the + /// next item is already being prepared. + func prepareForReplacement() { + invalidateActiveLoad() + engine.deactivatesAudioSessionOnStop = false + engine.stop(resetDisplayCriteria: false, finalTeardown: false) + refreshExternalPlaybackState() + publishSystemMediaChanged() + } + var isPaused: Bool { engine.state != .playing } #if os(iOS) || os(tvOS) @@ -369,18 +382,7 @@ final class AetherPlaybackController { } func stop() { - generation &+= 1 - transportIntentGeneration &+= 1 - transportRestoreTask?.cancel() - transportRestoreTask = nil - activeLoadEpoch = nil - hasCommittedActiveLoad = false - activeSpec = nil - configureExternalPlaybackPolicy() - aetherSubtitleIDByAppID = [:] - appSubtitleIDByAetherID = [:] - didPublishFirstFrame = false - didPublishEnd = false + invalidateActiveLoad() // Leaving video is the app's last use of the shared `AVAudioSession` unless an // audiobook is live. Aether never releases the session unless the host opts in // per teardown (#215, README "Who owns the audio session"), and a session left @@ -397,6 +399,21 @@ final class AetherPlaybackController { publishSystemMediaChanged() } + private func invalidateActiveLoad() { + generation &+= 1 + transportIntentGeneration &+= 1 + transportRestoreTask?.cancel() + transportRestoreTask = nil + activeLoadEpoch = nil + hasCommittedActiveLoad = false + activeSpec = nil + configureExternalPlaybackPolicy() + aetherSubtitleIDByAppID = [:] + appSubtitleIDByAetherID = [:] + didPublishFirstFrame = false + didPublishEnd = false + } + /// Seeds the alias map for the tracks Aether registers itself during /// `load`, whose ids are only knowable by position: Aether numbers /// `options.externalSubtitles` sequentially from diff --git a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift index 38e2a514..5ea1ab94 100644 --- a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift +++ b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift @@ -249,9 +249,10 @@ class PlayerViewModel { } /// Keeps the auxiliary Aether still decoder in the same lifetime as the - /// transport. Callers that own final teardown can await the returned task. + /// transport. Replacement loads preserve Aether's display/audio handoff; + /// callers that own final teardown can await the returned task. @discardableResult - private func disposeAetherPlayback() -> Task? { + private func disposeAetherPlayback(forReplacement: Bool = false) -> Task? { let previewShutdown = scrubPreviewProvider.endSession() activeAetherLoadEpoch = nil establishedAetherLoadEpoch = nil @@ -259,7 +260,11 @@ class PlayerViewModel { pendingProtocolV3FirstFrameEpoch = nil pendingProtocolV3SeekReanchorPosition = nil pendingProtocolV3TrackChange = nil - aetherPlaybackController.dispose() + if forReplacement { + aetherPlaybackController.prepareForReplacement() + } else { + aetherPlaybackController.dispose() + } return previewShutdown } @@ -3299,7 +3304,7 @@ class PlayerViewModel { currentStreamLoadGeneration == self.streamLoadGeneration else { return } do { - self.disposeAetherPlayback() + self.disposeAetherPlayback(forReplacement: true) guard !Task.isCancelled, !self.isDisposed else { return } // The init kicked off `settingsRefreshTask` to fetch the From 29f2ba02c667a88b28b101ba56fb449abbde23bb Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:16:53 +1000 Subject: [PATCH 2/2] fix(player): retain native host until successor load --- .../Screens/Player/AetherPlaybackController.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift b/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift index 5f160f2b..411e8371 100644 --- a/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift +++ b/iosApp/iosApp/Screens/Player/AetherPlaybackController.swift @@ -283,15 +283,15 @@ final class AetherPlaybackController { func dispose() { stop() } - /// Stops the outgoing item while keeping Aether's cross-item handoff - /// state alive for the replacement load. In particular, tvOS must not - /// reset the panel to its default display criteria between consecutive - /// episodes, and the shared audio session must not be released while the - /// next item is already being prepared. + /// Pauses the outgoing item while keeping Aether's native host mounted for + /// the replacement load. The next `engine.load` then owns the teardown and + /// can perform its native-to-native handoff without resetting the tvOS + /// display criteria, replacing the player layer, or releasing the shared + /// audio session in between consecutive episodes. func prepareForReplacement() { invalidateActiveLoad() engine.deactivatesAudioSessionOnStop = false - engine.stop(resetDisplayCriteria: false, finalTeardown: false) + engine.pause() refreshExternalPlaybackState() publishSystemMediaChanged() }