diff --git a/CHANGELOG.md b/CHANGELOG.md index ee23e99f..15a8cc03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 8644bede..f79aae82 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -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 diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 81131735..03aecd5f 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -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 @@ -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 diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index f0706403..ec3ebaca 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -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 @@ -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 @@ -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 @@ -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 } // 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. @@ -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) + } + + /// 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. @@ -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) @@ -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() @@ -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) @@ -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 @@ -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 diff --git a/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift b/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift index e7fda8a5..b087cb2c 100644 --- a/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift +++ b/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import AetherEngine @@ -51,3 +52,144 @@ struct MasterFallbackDecisionTests { errorCode: -11868, servingMasterPlaylist: true, alreadyFellBack: true)) } } + +/// #98: the media fallback reloads where the REJECTED item was placed. A recovery swaps a fresh item in +/// under a session that stays whole, so that is not always where the session first started. +/// +/// Field log, Apple TV 4K 3rd gen, tvOS 27.0, HDR10+ HEVC Matroska opened with a resume at 1844 s: +/// paused at 2099.69 s, the item died behind the screensaver, the #93 stage-2 recovery swapped a +/// fresh item in at 2099.69 s, that item was refused at startup with -11868, and the fallback +/// reloaded at the first mount's 1844 s (landing on the keyframe at 1834.79 s). The viewer pressed +/// play four minutes behind the pause; a session started from the beginning of a title is put back +/// to its first frame. +@Suite("#98: the media fallback comes back where the rejected item was placed") +@MainActor +struct MasterFallbackPositionTests { + + private let url = URL(fileURLWithPath: "/nonexistent-master-fallback-position-test.m3u8") + + @Test("An in-place recovery swap moves the placement the fallback reads") + func recoverySwapMovesThePlacement() { + let host = NativeAVPlayerHost() + defer { host.tearDown() } + + host.load(url: url, startPosition: 1844, contract: .init()) + #expect(host.mountedStartPosition == 1844) + + // The #93/#65 stage-2 recovery: same session, fresh item, placed where playback stood. + host.swapItem(url: url, startPosition: 2099.69) + #expect(host.mountedStartPosition == 2099.69) + } + + @Test("A mount with no start position is placed at the head, as its seek is") + func nilStartIsTheHead() { + let host = NativeAVPlayerHost() + defer { host.tearDown() } + + host.load(url: url, startPosition: nil, contract: .init()) + #expect(host.mountedStartPosition == 0) + } + + @Test("A live rejoin makes no start seek, so it records no placement") + func liveRejoinRecordsNoPlacement() { + let host = NativeAVPlayerHost() + defer { host.tearDown() } + + host.load(url: url, startPosition: 30, contract: .init(isLive: true)) + host.swapItem(url: url, startPosition: nil, skipInitialSeek: true) + #expect(host.mountedStartPosition == nil) + } + + /// A host-level test cannot see the call site, so this one reads it, as the #535 latch test does. + @Test("The fallback reads the placement of the item it replaces") + func fallbackReadsThePlacement() throws { + let source = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/AetherEngine/AetherEngine.swift") + let text = try #require(try? String(contentsOf: source, encoding: .utf8)) + let fn = try #require(text.range(of: "func fallBackToMediaPlaylist(")) + let body = String(text[fn.lowerBound...].prefix(4000)) + #expect(body.contains("host.mountedStartPosition")) + } +} + +/// #93/#98: when a dead item's recovery reload is refused, the media fallback replaces it. Whether +/// the fallback then PLAYS is the viewer's call. The engine's intent (#122) misses a Play from AVKit, +/// Control Center or PiP, so AVPlayer's rate is read as well. +@Suite("#98: the media fallback resumes only a viewer who was playing") +@MainActor +struct MasterFallbackTransportTests { + private func resumes( + command: Bool? = nil, intent: Bool, rolled: Bool, pausedBefore: Bool + ) -> Bool { + NativeAVPlayerHost.mediaFallbackResumesPlaying( + commandSinceRejection: command, intentIsPlaying: intent, + rolledSinceEngineStop: rolled, pausedBeforeRejection: pausedBefore) + } + + @Test("An item the engine was playing is replaced playing") + func enginePlayResumes() { + #expect(resumes(intent: true, rolled: false, pausedBefore: false)) + } + + @Test("A Play from AVKit, Control Center or PiP, which leaves the intent clear, is kept") + func externalPlayResumes() { + #expect(resumes(intent: false, rolled: true, pausedBefore: false)) + } + + @Test("A viewer who paused before the refusal stays paused, whichever way the pause came") + func pauseBeforeRejectionStaysPaused() { + #expect(!resumes(intent: true, rolled: true, pausedBefore: true)) + #expect(!resumes(intent: false, rolled: true, pausedBefore: true)) + } + + /// An engine pause inside the one-second margin reads as the refusal's own stop, but it clears the + /// intent and the roll, so the viewer's pause still holds. + @Test("An engine pause just before the refusal stays paused") + func enginePauseInsideTheMarginStaysPaused() { + #expect(!resumes(intent: false, rolled: false, pausedBefore: false)) + } + + @Test("A Play or Pause through the engine after the refusal decides") + func commandAfterRejectionDecides() { + #expect(resumes(command: true, intent: true, rolled: false, pausedBefore: true)) + #expect(!resumes(command: false, intent: false, rolled: true, pausedBefore: false)) + } + + /// Engine commands that drive the host's records, on a real host with a mounted item. + @Test("The host's records follow engine and external transport") + func hostRecordsFollowTransport() { + let host = NativeAVPlayerHost() + defer { host.tearDown() } + host.load(url: URL(fileURLWithPath: "/nonexistent-master-fallback-transport-test.m3u8"), + startPosition: 0, contract: .init()) + // A fresh load nobody played: stays paused. + #expect(!host.mediaFallbackResumesPlaying()) + host.play() + #expect(host.mediaFallbackResumesPlaying()) + host.pause() + #expect(!host.mediaFallbackResumesPlaying()) + } + + /// The fallback needs a live loopback session to run, so this reads its call site, as the + /// placement test above does. + @Test("The media fallback plays only on the host's verdict, read before the swap") + func fallbackAsksTheHost() throws { + let source = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/AetherEngine/AetherEngine.swift") + let text = try #require(try? String(contentsOf: source, encoding: .utf8)) + let fn = try #require(text.range(of: "func fallBackToMediaPlaylist(")) + let end = try #require(text[fn.upperBound...].range(of: "\n }\n")) + let body = String(text[fn.lowerBound..