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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ the public-API contract.

### Fixed

- A paused video no longer starts playing by itself. When the player item died while paused (`failedToPlayToEndTime`), the recovery reload bypassed the pause guard and called `play()` on the fresh item. The reload now keeps a pause made before the item died, whether it came through the engine, AVKit, Control Center or PiP, and mounts the item paused at the same position.
- TrueHD Atmos rendered to APAC (`LoadOptions.objectAudioRendering`) no longer plays 42.7 ms ahead of the video. The bridge dropped the encoder's 2048 frames of priming and stamped the first content packet on the source position, but AVFoundation presents an APAC packet's audio 2048 frames before its timestamp, so every session ran early, at load and after every seek. The priming packets now stay in the stream and take the source position's timestamp.
- Authorized native HLS uses the engine relay from the initial load, without forwarding origin credentials to the loopback asset. Optional subtitle playlist preparation shares the authorizer and has a bounded deadline across redirects and refreshes.
- Static-header HLS redirects apply the shared credential policy, including Emby and MediaBrowser token headers, before contacting another origin.
Expand Down
13 changes: 11 additions & 2 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,7 @@ extension AetherEngine {
.sink { [weak self, weak host] count in
guard let self, let host else { return }
let clockAtFailure = host.renderedTime
let diedUnderPause = host.endFailureFollowedPause
Comment thread
kody-ai[bot] marked this conversation as resolved.
self.itemDeathConfirmTask?.cancel()
self.itemDeathConfirmTask = Task { @MainActor [weak self, weak host] in
try? await Task.sleep(
Expand Down Expand Up @@ -1585,12 +1586,20 @@ extension AetherEngine {
)
return
}
// Decided now rather than when the failure was counted: the viewer may have
// pressed Play or Pause while the death was being confirmed.
let resumesPlaying = NativeAVPlayerHost.itemDeathReloadResumesPlaying(
diedUnderPause: diedUnderPause,
commandSinceFailure: host.transportCommandSinceEndFailure,
transportRolling: host.rate != 0)
EngineLog.emit(
"[AetherEngine] #93 item death (failedToPlayToEndTime) at "
+ "\(String(format: "%.2f", position))s; reloading item through stage-2 "
+ "recovery (attempt \(self.itemDeathReviveGate.attempts), pause guard bypassed)",
+ "recovery (attempt \(self.itemDeathReviveGate.attempts), pause guard bypassed"
+ (resumesPlaying ? ")" : ", keeping the viewer's pause)"),
category: .engine)
self.reloadStalledConsumerItem(position: position, allowPausedConsumer: true)
self.reloadStalledConsumerItem(
position: position, allowPausedConsumer: true, resumesPlaying: resumesPlaying)
}
}
.store(in: &nativeCancellables)
Expand Down
15 changes: 13 additions & 2 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2830,7 +2830,12 @@ public final class AetherEngine: ObservableObject {
/// `LiveReloadPolicy.recoveryRejoinPosition` cannot. A window closed with ENDLIST is a finite asset
/// whose seekable end IS the playhead, so the distance-behind-live that policy reads is zero and it
/// would aim at the edge, discarding the rewind the viewer kept through the whole outage.
/// - Parameter resumesPlaying: false when the viewer wants the item paused (see
/// `NativeAVPlayerHost.itemDeathReloadResumesPlaying`). The pause guard bypass admits the dead
/// item; it must not also overrule the viewer, so the fresh item mounts paused at the anchor
/// and the next Play resumes there.
func reloadStalledConsumerItem(position: Double, allowPausedConsumer: Bool = false,
resumesPlaying: Bool = true,
liveRejoinOverride: Double? = nil) {
guard let host = nativeHost, let player = currentAVPlayer,
let url = (player.currentItem?.asset as? AVURLAsset)?.url else { return }
Expand Down Expand Up @@ -2873,7 +2878,7 @@ public final class AetherEngine: ObservableObject {
+ Self.recoveryAnchorLogSuffix(
anchor: anchor, position: position,
pendingSeekTarget: pendingRecoverySeekClockTarget)
+ " (same URL, same host)",
+ " (same URL, same host" + (resumesPlaying ? ")" : ", staying paused for the viewer)"),
category: .engine
)
// AE#454: the placement, expressed in the playlist the fresh item is about to load. A rejoin
Expand Down Expand Up @@ -2913,7 +2918,13 @@ public final class AetherEngine: ObservableObject {
// AE#454 round 2: the item that is about to load is the one the placement was armed for, and
// the only one whose axis the playlist will state.
if didArmPlacement { liveRejoinPlacementGeneration = host.itemGeneration }
host.play()
if resumesPlaying {
host.play()
} else {
// Clears the intent latch a pause from AVKit, Control Center or PiP left set, so the fresh
// item's readyToPlay does not re-assert play() behind the viewer.
host.pause()
}
if let rejoinPosition {
// Stashed rather than seeked: the pre-readiness seek IS the wedge LiveReloadPolicy exists
// to avoid, and a live seek does not defer itself (`shouldDeferHostSeek` excludes live), so
Expand Down
93 changes: 93 additions & 0 deletions Sources/AetherEngine/Native/NativeAVPlayerHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ final class NativeAVPlayerHost {
/// duration, and publishing that transient would bounce the engine through `.paused` and back for
/// what the viewer must not even notice; the real status is republished when the recovery settles.
private var prematureEndRecoveryInFlight = false
/// Uptimes bounding the last premature-end recovery. A rate change AVPlayer reported inside that
/// interval belongs to the recovery, even when its main-actor hop runs after the recovery ended.
private var prematureEndRecoveryStartedUptime: UInt64 = 0
private var prematureEndRecoveryEndedUptime: UInt64 = 0
/// Uptime of the newest transport event applied to `pausedSinceUptime`. Rate reports reach the
/// main actor after engine commands issued later, so an older report must not overwrite them.
private var transportStampEventUptime: UInt64 = 0
/// Mirrors avPlayer.timeControlStatus so the engine can reconcile when AVKit's transport bar, Control Center, or hardware buttons toggle the player externally (without this, engine state goes stale and play/pause presses are swallowed).
@Published private(set) var timeControlStatus: AVPlayer.TimeControlStatus = .paused
/// Monotonic count of AVPlayerItem playbackStalled notifications (#93 residual): the engine
Expand All @@ -124,6 +131,18 @@ final class NativeAVPlayerHost {
/// at .paused, which every pause-guarded recovery layer misreads as user intent; the engine
/// subscribes and escalates into the stage-2 item reload with the pause guard bypassed.
@Published private(set) var endFailureCount: Int = 0
/// Whether the transport had already stopped before the latest counted end failure: the viewer
/// paused (through the engine, AVKit, Control Center or PiP) and the item died under that pause.
/// Set before `endFailureCount` publishes, so its subscribers read the value for their failure.
private(set) var endFailureFollowedPause = false
/// The latest engine-routed transport command since the latest counted end failure: true for
/// play, false for pause, nil for none. A viewer can press either while the engine confirms the
/// death, and that press outranks the transport state the item died in.
private(set) var transportCommandSinceEndFailure: Bool?
/// Uptime at which the commanded transport stopped: stamped when AVPlayer's `rate` drops to 0 or an
/// engine-routed pause lands, cleared by any non-zero rate. The rate is what play and pause set,
/// from any source, even on an item that cannot roll; `timeControlStatus` only reports the outcome.
private var pausedSinceUptime: UInt64?
/// End of the last seekable time range (seconds); tracks the live edge for EVENT playlists.
/// KVO mirror of `seekableTimeRanges`, NOT a live read: the getter is a sync XPC round-trip
/// to mediaserverd, and clock-tick sinks plus the 1 Hz paused-live timer read this at a
Expand Down Expand Up @@ -636,10 +655,19 @@ final class NativeAVPlayerHost {

rateObservation = avPlayer.observe(\.rate, options: [.new]) { [weak self] player, _ in
let rate = player.rate
let observedAt = DispatchTime.now().uptimeNanoseconds
EngineLog.emit("[NativeAVPlayerHost] #\(sid) rate=\(rate)", category: .engine)
Task { @MainActor in
guard let self, self.sessionID == sid else { return }
self.rate = rate
// AE#287: a stop AVPlayer reported during the premature-end re-seek is the recovery's,
// not the viewer's. Judged by when AVPlayer reported it: this hop can run after the
// recovery has ended.
let recoveryOwned = observedAt >= self.prematureEndRecoveryStartedUptime
&& (self.prematureEndRecoveryInFlight || observedAt <= self.prematureEndRecoveryEndedUptime)
if rate != 0 || !recoveryOwned {
self.stampTransport(rolling: rate != 0, at: observedAt)
}
}
}

Expand Down Expand Up @@ -745,6 +773,10 @@ final class NativeAVPlayerHost {
surfaceEndFailures: false, hasEverPlayed: self.hasEverPlayed) {
// #93 round 3: loopback path. Count the death for the engine's revive
// escalation; a startup death (never played) stays with the startup watchdogs.
self.endFailureFollowedPause = Self.transportPausedBeforeFailure(
pausedSinceUptime: self.pausedSinceUptime,
failureUptime: DispatchTime.now().uptimeNanoseconds)
self.transportCommandSinceEndFailure = nil
self.endFailureCount += 1
}
}
Expand Down Expand Up @@ -896,6 +928,44 @@ final class NativeAVPlayerHost {
!surfaceEndFailures && hasEverPlayed
}

/// Keeps `pausedSinceUptime` on the commanded transport: cleared when it rolls, stamped once when
/// it stops and left alone while it stays stopped.
private func stampTransport(rolling: Bool, at uptime: UInt64 = DispatchTime.now().uptimeNanoseconds) {
guard uptime >= transportStampEventUptime else { return }
Comment thread
neurekadev marked this conversation as resolved.
transportStampEventUptime = uptime
if rolling {
pausedSinceUptime = nil
} else if pausedSinceUptime == nil {
pausedSinceUptime = uptime
}
}

/// The dead item's own drop to rate 0 and its `failedToPlayToEndTime` land within a runloop turn
/// of each other, in either order (the two are unsynchronized, see #50). A stop older than this
/// was the viewer's.
nonisolated static let pausedBeforeFailureMarginSeconds: Double = 1.0

/// Pure decision: had the transport already stopped when the item died? Read from AVPlayer's
/// own `rate` rather than the #122 intent latch, because AVKit's transport bar, Control Center
/// and PiP pause and resume the player without passing through the engine.
nonisolated static func transportPausedBeforeFailure(
pausedSinceUptime: UInt64?, failureUptime: UInt64
) -> Bool {
guard let pausedSinceUptime, failureUptime > pausedSinceUptime else { return false }
let pausedSeconds = Double(failureUptime - pausedSinceUptime) / 1_000_000_000
return pausedSeconds >= pausedBeforeFailureMarginSeconds
}

/// Pure decision: does the reload of a dead item restart transport? A Play or Pause pressed
/// through the engine after the failure decides. Otherwise a transport rolling again (a Play from
/// AVKit, Control Center or PiP) resumes, and one that had stopped before the failure stays paused.
nonisolated static func itemDeathReloadResumesPlaying(
diedUnderPause: Bool, commandSinceFailure: Bool?, transportRolling: Bool
) -> Bool {
if let commandSinceFailure { return commandSinceFailure }
return transportRolling || !diedUnderPause
Comment thread
neurekadev marked this conversation as resolved.
}

/// #50: AVPlayer fires .failed for self-healing transients (loopback 404, AVIOReader reconnect) while playback advances uninterrupted (rrgomes: tcs=playing at .failed).
/// Discriminates on hasEverPlayed, not instantaneous timeControlStatus: .failed and timeControlStatus KVOs are unsynchronized (426b45c: still published terminal failure at 27.3s while AVPlayer played smoothly).
/// Before first .playing: surface promptly (genuine startup failure). After: defer 5s and confirm -- clear if .playing or clock advanced, surface if both stopped.
Expand Down Expand Up @@ -1637,12 +1707,18 @@ final class NativeAVPlayerHost {
func play() {
// Set intent before play() so readyToPlay observer can re-assert if the replaceCurrentItem swap swallowed it.
playIntent = true
transportCommandSinceEndFailure = true
stampTransport(rolling: true)
// Call play() immediately (no defer-until-ready): item.status never advances past .unknown until AVPlayer is told to play.
avPlayer.play()
}

func pause() {
playIntent = false
transportCommandSinceEndFailure = false
// Stamped here as well as from the rate KVO: pausing a player whose rate is already 0 (a dead
// or parked item) changes nothing AVPlayer reports, and the viewer's pause must still count.
stampTransport(rolling: false)
avPlayer.pause()
}

Expand Down Expand Up @@ -1694,6 +1770,7 @@ final class NativeAVPlayerHost {
prematureEndRecoveryAttempts += 1
lastPrematureEndRecoveryPlayhead = playhead
prematureEndRecoveryInFlight = true
prematureEndRecoveryStartedUptime = DispatchTime.now().uptimeNanoseconds
EngineLog.emit(
"[NativeAVPlayerHost] #\(sessionID) AE#287 premature end: playhead="
+ "\(String(format: "%.3f", playhead))s duration=\(String(format: "%.3f", duration))s "
Expand All @@ -1707,8 +1784,22 @@ final class NativeAVPlayerHost {
// The session may have been handed over while the seek was in flight; a retired session
// must not restart the player under its successor.
guard sessionID == sid else { return true }
// A viewer who paused through the engine while the re-seek was in flight keeps the pause.
guard playIntent else {
prematureEndRecoveryInFlight = false
prematureEndRecoveryEndedUptime = DispatchTime.now().uptimeNanoseconds
timeControlStatus = avPlayer.timeControlStatus
EngineLog.emit(
"[NativeAVPlayerHost] #\(sessionID) AE#287 re-seeked; staying paused for the viewer",
category: .engine)
return true
}
avPlayer.play()
prematureEndRecoveryInFlight = false
prematureEndRecoveryEndedUptime = DispatchTime.now().uptimeNanoseconds
// The premature end stopped the rate before the recovery began, and nobody paused: the
// recovery has now commanded play, so drop that stamp even if AVPlayer's rate has not moved.
stampTransport(rolling: true)
Comment thread
kody-ai[bot] marked this conversation as resolved.
timeControlStatus = avPlayer.timeControlStatus
let resumedAt = await prematureEndReading().playhead
EngineLog.emit(
Expand Down Expand Up @@ -1888,6 +1979,8 @@ final class NativeAVPlayerHost {
func setRate(_ value: Float) {
// Non-zero rate counts as play intent (must survive replaceCurrentItem swap like play() does).
playIntent = (value != 0)
transportCommandSinceEndFailure = (value != 0)
stampTransport(rolling: value != 0)
// #436: `play()` is rate 1.0 by definition, and it is re-issued from paths no client can see:
// the readyToPlay re-assert after an item swap, interruption and background resume, the #287
// premature-end recovery, plus AVKit's own transport and the remote command centre calling
Expand Down
53 changes: 53 additions & 0 deletions Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,59 @@ struct Issue93ItemDeathReviveTests {
consumerIsPaused: false, allowPausedConsumer: false))
}

// MARK: - Viewer pause before the death

private static let second: UInt64 = 1_000_000_000

@Test("an item that died under a viewer's pause is reloaded paused")
func deathUnderViewerPause() {
// The field report: paused on an Apple TV, the item died minutes later and the reload
// started playback with nobody touching the remote.
#expect(NativeAVPlayerHost.transportPausedBeforeFailure(
pausedSinceUptime: 10 * Self.second, failureUptime: 460 * Self.second))
}

@Test("an item that died while rolling is reloaded playing")
func deathWhileRolling() {
#expect(!NativeAVPlayerHost.transportPausedBeforeFailure(
pausedSinceUptime: nil, failureUptime: 460 * Self.second))
}

@Test("the dead item's own pause, landing just before the notification, is not the viewer's")
func deathParksItsOwnPause() {
#expect(!NativeAVPlayerHost.transportPausedBeforeFailure(
pausedSinceUptime: 460 * Self.second - Self.second / 20,
failureUptime: 460 * Self.second))
}

@Test("a pause stamped after the notification is not the viewer's")
func pauseAfterNotification() {
#expect(!NativeAVPlayerHost.transportPausedBeforeFailure(
pausedSinceUptime: 461 * Self.second, failureUptime: 460 * Self.second))
}

@Test("with no press since the death, the transport it died in decides")
func reloadFollowsTransportAtDeath() {
#expect(!NativeAVPlayerHost.itemDeathReloadResumesPlaying(
diedUnderPause: true, commandSinceFailure: nil, transportRolling: false))
#expect(NativeAVPlayerHost.itemDeathReloadResumesPlaying(
diedUnderPause: false, commandSinceFailure: nil, transportRolling: false))
}

@Test("a Play or Pause pressed while the death is confirmed outranks the transport it died in")
func pressDuringConfirmationDecides() {
#expect(NativeAVPlayerHost.itemDeathReloadResumesPlaying(
diedUnderPause: true, commandSinceFailure: true, transportRolling: false))
#expect(!NativeAVPlayerHost.itemDeathReloadResumesPlaying(
diedUnderPause: false, commandSinceFailure: false, transportRolling: true))
}

@Test("a Play from outside the engine after a paused death resumes the reload")
func externalPlayAfterPausedDeath() {
#expect(NativeAVPlayerHost.itemDeathReloadResumesPlaying(
diedUnderPause: true, commandSinceFailure: nil, transportRolling: true))
}

// MARK: - Host-side counting decision

@Test("loopback path counts an end failure after playback was established")
Expand Down
Loading
Loading