diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be3e8769..ee23e99f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 6a1671974..8644beded 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -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 self.itemDeathConfirmTask?.cancel() self.itemDeathConfirmTask = Task { @MainActor [weak self, weak host] in try? await Task.sleep( @@ -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) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index b2adb8b62..811317357 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -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 } @@ -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 @@ -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 diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index cb4bb23b8..f07064031 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -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 @@ -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 @@ -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) + } } } @@ -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 } } @@ -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 } + 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 + } + /// #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. @@ -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() } @@ -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 " @@ -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) timeControlStatus = avPlayer.timeControlStatus let resumedAt = await prematureEndReading().playhead EngineLog.emit( @@ -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 diff --git a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift index aaa1d744c..91b13029c 100644 --- a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift +++ b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift @@ -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") diff --git a/docs/architecture.md b/docs/architecture.md index 6c6ee1d7c..2685ea960 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ What a host observes about a seek comes in two shapes. `isSeeking` / `seekTarget Restart latency is self-localizing (#93 follow-up): the "producer restarted" line carries a phase split (`stopWait/reopen/seek/build`), a producer's FIRST source read is timed, and any single `AVIOReader` read exceeding 2 s emits one `slow read` summary naming where the time went (detour fetches with network time, `connStallTimeout` waits, reconnects, backoff sleeps, bytes dropped by the stale-generation guard, generation span). A slow read with all-zero counters means the wait was upstream of the read loop. -A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). +A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `rate` dropped to 0, so a pause made before the death counts whether it came from the engine, AVKit, Control Center or PiP, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. An engine-routed pause is stamped directly; a pause from AVKit or Control Center is seen only through the rate change. During the three-second confirmation window, a Play or Pause through the engine decides and a Play from outside the engine (a non-zero rate) resumes. A pause from AVKit or Control Center on a player whose rate is already 0 (the dead item in that window, or an item left stopped by a recovery) is not observed, because AVPlayer reports no change. When a restart does run, it must reproduce segments on the SAME media timeline the continuous run gave them: the loopback's contract with AVPlayer is "static VOD server", and AVPlayer anchors fMP4 segments by their `tfdt`. Each restart allocates a fresh mp4 muxer, and movenc zero-bases a new instance's timeline by default, so a restart-produced segment used to carry `tfdt=0` while the playlist placed it at its plan offset: an implicit timeline discontinuity on every restart, papered over for plain playback but fatal to ancillary consumers (AVKit's legible renderer detaches mid-PiP, Sodalite#32; playhead/loaded-range decoupling, #93). The muxer therefore sets `movflags +frag_discont` with `avoid_negative_ts=disabled` so `tfdt` carries the producer's absolute output timestamps, the restart audio gate inherits the session shift (video shift rescaled) instead of snapping audio onto the video seam, and leading head-of-stream audio that would map below 0 is dropped (the muxer no longer absorbs negative timestamps). A restarted segment is byte-identical to its continuous twin modulo the per-muxer `mfhd` sequence number (pinned by `RestartTimelineContinuityTests` on a committed A/V fixture); on matroska sources, per-sample DTS synthesis after a demuxer seek scatters the DTS decomposition and boundary-frame membership by a frame or two, but presentation timestamps and `tfdt` anchoring stay epoch-invariant. Because `tfdt` carries `unsigned int(64)`, that same setting makes a negative output axis unrepresentable rather than merely unusual, so the published first timestamp is clamped at zero (AE#509): libavformat serves an MPEG-TS whose first DTS sits within 60 s of the 33-bit PTS wrap with every timestamp `2^33` ticks low (`AV_PTS_WRAP_SUB_OFFSET`), and a live join there published `baseMediaDecodeTime = 2^64 - |dts|` against a playlist starting at 0, which AVPlayer answers by fetching the whole window and placing none of it, with no error and no stall of its own.