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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ 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.
- A dead item's recovery no longer restarts the title from where the session was first opened. When AVPlayer refused the recovery item's master (`-11868`), the media fallback reloaded at the first mount's start position, so a title opened from its beginning restarted at 0:00. The fallback now reloads where the refused item was placed. Upstream #621.
- The media fallback no longer starts a paused title. When the recovery item was refused, the fallback called `play()` unconditionally, so a title paused behind the tvOS screensaver started itself. It now plays only when the refused item was playing, or was told to play, and the viewer had not paused it. A Play or Pause from AVKit, Control Center or PiP counts as well as one through the engine.
- 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
1 change: 0 additions & 1 deletion Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1638,7 +1638,6 @@ extension AetherEngine {
// appliesPerFrameHDRDisplayMetadata unconditionally true: DV P5 has no HDR10 base layer, so the per-frame RPU is what AVPlayer's tone-mapper needs on a non-DV panel (DrHurt #4 2026-05-26). Prior servingMasterPlaylist gate broke P5. Apple's default is also true; explicit write surfaces the live value in diagnostics.
// forwardBufferDuration default (4 s): deep buffer lets AVPlayer race to the live edge and hit the transcode warm-up gap head-on (-12888); 4 s PACES consumption. Verified: 8 s worsened startup pause (8-10 s vs ~1 s).
// Live REJOIN: skip initial seek so AVPlayer picks edge-minus-holdback instead; seek-to-0 against the re-served backlog wedged the reloaded item in waitingToPlay (device repro: tvOS 26, Jellyfin stream.ts). See LiveReloadPolicy.
lastNativeVideoStartPosition = startPosition ?? 0
// Sequential append playlist: AVPlayer treats the growing playlist as an EVENT and
// defaults to edge-minus-holdback (~6 s in on a fresh session, more once the producer
// has raced ahead). The load-time seek to 0 fires before readyToPlay and the item
Expand Down
25 changes: 17 additions & 8 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2296,10 +2296,6 @@ public final class AetherEngine: ObservableObject {
/// session so a media reload that also fails cannot loop. Reset on each load.
var masterFallbackUsed = false

/// Start position of the current loopback video load, replayed if the master is rejected and we
/// reload the media playlist (a startup-failed item has no reliable renderedTime).
var lastNativeVideoStartPosition: Double = 0

/// #93 PiP skips: AVKit-side seeks (PiP +-15s buttons) bypass the engine seek API, so a far
/// playhead jump is detected on $renderedTime and, once settled, the native subtitle readers
/// re-anchor and the remembered rendition selection replays (its deselect/reselect busts
Expand Down Expand Up @@ -2479,17 +2475,30 @@ public final class AetherEngine: ObservableObject {
// #130: a live fallback is a REJOIN of the running ingest (the window may have slid since
// the failed master attempt); a stale explicit position can wedge AVPlayer against the
// backlog, so skip the initial seek and let it pick edge-minus-holdback (LiveReloadPolicy).
// VOD keeps the explicit pre-failure position.
let position = lastNativeVideoStartPosition
// VOD reloads where the rejected item was placed. That is not always where the session
// started: the #93/#65 stage-2 recovery swaps a fresh item in at the position it held, and a
// rejection of THAT item has to come back there, not rewind to the first mount.
let position = host.mountedStartPosition ?? 0
// Read before the swap, which resets what it reads.
let resumesPlaying = host.mediaFallbackResumesPlaying()
EngineLog.emit(
"[AetherEngine] AVPlayer rejected the master (code=\(rejection.code)); falling back to "
+ "media playlist (no CC/subtitle renditions) at "
+ (isLive ? "the live edge" : "\(String(format: "%.2f", position))s"),
+ (isLive ? "the live edge" : "\(String(format: "%.2f", position))s")
+ (resumesPlaying ? "" : ", staying paused for the viewer"),
category: .session)
host.swapItem(url: fallbackURL,
startPosition: isLive ? nil : position,
skipInitialSeek: LiveReloadPolicy.skipInitialSeek(isLive: isLive, isRejoin: true))
host.play()
// Resume only a viewer who was playing, read from both the engine's intent and AVPlayer's
// rate. A paused title refused behind the tvOS screensaver used to start itself and wake it.
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()
}
}

/// #35 readiness-gate settle windows. Generous enough that a slow-but-healthy cold start reads as
Expand Down
66 changes: 66 additions & 0 deletions Sources/AetherEngine/Native/NativeAVPlayerHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ final class NativeAVPlayerHost {
/// `SessionLoadContract` and `swapItem`.
private(set) var sessionContract = SessionLoadContract()

/// Where the current item was placed when it was mounted: the explicit start seek `load` makes,
/// or nil when it joined without one (a live rejoin). Recorded on every mount, the in-place swaps
/// included, so a recovery that has to replace this item puts the next one where THIS one was,
/// not where the session first started (#98). A startup-failed item has no reliable
/// `renderedTime`, which is why the fallback reads the placement rather than the clock.
private(set) var mountedStartPosition: Double?

/// Set per load; gates the AE#287 premature-end recovery, which only makes sense for a fixed-length
/// presentation. A live session has no advertised end to fall short of.
private var isLiveSession: Bool = false
Expand Down Expand Up @@ -143,6 +150,18 @@ final class NativeAVPlayerHost {
/// 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?
/// Whether AVPlayer's rate went non-zero since the engine last stopped the transport (a pause, a
/// zero rate or a fresh load), judged by when AVPlayer reported it. A Play from AVKit, Control
/// Center or PiP moves the rate but not `playIntent`, so this is the only record of it. An in-place
/// swap carries the outgoing item's rate in.
private var rolledSinceEngineStop = false
/// Uptime of the engine's last transport stop. A rate report older than it is not a roll.
private var engineStopUptime: UInt64 = 0
/// Uptime of the latest master refusal handed to the engine (#98), and the engine-routed transport
/// command since it: true for play, false for pause, nil for none. The media fallback runs a task
/// hop after the refusal and reads both then.
private var displayRejectionUptime: UInt64 = 0
private var transportCommandSinceRejection: Bool?
/// 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 @@ -450,6 +469,7 @@ final class NativeAVPlayerHost {
unloadCurrentItem(inPlaceSwap: inPlaceSwap)

self.sessionContract = contract
mountedStartPosition = skipInitialSeek ? nil : (startPosition ?? 0)
let forwardBufferDuration = contract.forwardBufferDuration
let httpHeaders = contract.httpHeaders
let armIngestFallback = contract.armIngestFallback
Expand Down Expand Up @@ -660,6 +680,7 @@ final class NativeAVPlayerHost {
Task { @MainActor in
guard let self, self.sessionID == sid else { return }
self.rate = rate
if rate != 0, observedAt >= self.engineStopUptime { self.rolledSinceEngineStop = true }
Comment thread
Quick104 marked this conversation as resolved.
Comment thread
Quick104 marked this conversation as resolved.
// 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.
Expand Down Expand Up @@ -966,6 +987,39 @@ final class NativeAVPlayerHost {
return transportRolling || !diedUnderPause
}

/// Whether the media fallback replacing the latest refused master (#98) should play. The engine
/// asks when the fallback runs, a task hop after the refusal, so a rate report or a transport
/// command raced with the refusal has landed, and before it swaps, which resets the records read.
func mediaFallbackResumesPlaying() -> Bool {
Self.mediaFallbackResumesPlaying(
commandSinceRejection: transportCommandSinceRejection,
intentIsPlaying: playIntent,
rolledSinceEngineStop: rolledSinceEngineStop,
pausedBeforeRejection: Self.transportPausedBeforeFailure(
pausedSinceUptime: pausedSinceUptime, failureUptime: displayRejectionUptime))
}

/// Pure decision: does the media fallback play the item that replaces a refused master? A Play or
/// Pause through the engine after the refusal decides. Otherwise a refused item was told to play,
/// since `item.status` does not advance before that, so it plays on unless the viewer paused it
/// before the refusal (the same one-second margin as an item death). Who told it to play is read
/// from both records: `playIntent` for the engine, and a roll since the engine's last stop for
/// AVKit, Control Center or PiP, which never touch the intent. An engine pause inside the margin
/// still counts, because it clears both.
nonisolated static func mediaFallbackResumesPlaying(
commandSinceRejection: Bool?, intentIsPlaying: Bool, rolledSinceEngineStop: Bool,
pausedBeforeRejection: Bool
) -> Bool {
if let commandSinceRejection { return commandSinceRejection }
return !pausedBeforeRejection && (intentIsPlaying || rolledSinceEngineStop)
Comment thread
Quick104 marked this conversation as resolved.
}

/// The engine stopped the transport: a roll reported before now no longer says anyone wants it.
private func noteEngineStop() {
engineStopUptime = DispatchTime.now().uptimeNanoseconds
rolledSinceEngineStop = false
}

/// #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 @@ -1037,6 +1091,8 @@ final class NativeAVPlayerHost {
"[NativeAVPlayerHost] #\(sessionID) startup .failed is a master rejection "
+ "(code=\(code)); signalling engine for media fallback instead of surfacing",
category: .engine)
displayRejectionUptime = DispatchTime.now().uptimeNanoseconds
transportCommandSinceRejection = nil
pendingDisplayRejection = DisplayRejection(code: code,
message: desc,
domain: (item.error as NSError?)?.domain)
Expand Down Expand Up @@ -1708,6 +1764,7 @@ final class NativeAVPlayerHost {
// Set intent before play() so readyToPlay observer can re-assert if the replaceCurrentItem swap swallowed it.
playIntent = true
transportCommandSinceEndFailure = true
transportCommandSinceRejection = 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()
Expand All @@ -1716,6 +1773,8 @@ final class NativeAVPlayerHost {
func pause() {
playIntent = false
transportCommandSinceEndFailure = false
transportCommandSinceRejection = false
noteEngineStop()
// 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)
Expand Down Expand Up @@ -1980,6 +2039,8 @@ final class NativeAVPlayerHost {
// Non-zero rate counts as play intent (must survive replaceCurrentItem swap like play() does).
playIntent = (value != 0)
transportCommandSinceEndFailure = (value != 0)
transportCommandSinceRejection = (value != 0)
if value == 0 { noteEngineStop() }
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
Expand Down Expand Up @@ -2156,6 +2217,11 @@ final class NativeAVPlayerHost {
readinessDeadlineSeconds = nil
// Re-arm #50 hasEverPlayed: reused host must not inherit prior session's established state.
hasEverPlayed = false
if inPlaceSwap {
rolledSinceEngineStop = avPlayer.rate != 0
} else {
noteEngineStop()
}
// #93 recovery reload: same content, same position, playback must continue. Skip the
// pause + nil-item gap below (PiP content-source invalidation + transport bounce); the
// old item keeps playing until replaceCurrentItem swaps in the fresh one, and playIntent
Expand Down
Loading
Loading