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
25 changes: 25 additions & 0 deletions iosApp/Tests/AetherPlaybackBoundaryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 29 additions & 12 deletions iosApp/iosApp/Screens/Player/AetherPlaybackController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,19 @@ final class AetherPlaybackController {

func dispose() { stop() }

/// 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.pause()
refreshExternalPlaybackState()
publishSystemMediaChanged()
}

var isPaused: Bool { engine.state != .playing }

#if os(iOS) || os(tvOS)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 9 additions & 4 deletions iosApp/iosApp/Screens/Player/PlayerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,17 +249,22 @@ 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<Void, Never>? {
private func disposeAetherPlayback(forReplacement: Bool = false) -> Task<Void, Never>? {
let previewShutdown = scrubPreviewProvider.endSession()
activeAetherLoadEpoch = nil
establishedAetherLoadEpoch = nil
committedProtocolV3LoadEpoch = nil
pendingProtocolV3FirstFrameEpoch = nil
pendingProtocolV3SeekReanchorPosition = nil
pendingProtocolV3TrackChange = nil
aetherPlaybackController.dispose()
if forReplacement {
aetherPlaybackController.prepareForReplacement()
} else {
aetherPlaybackController.dispose()
}
return previewShutdown
}

Expand Down Expand Up @@ -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
Expand Down