From a48b2bbf1823f8a6bb91747c542e0d0cb37d2f11 Mon Sep 17 00:00:00 2001 From: Brandon Moore <16313090+thatcube@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:23:09 -0400 Subject: [PATCH 1/4] fix(native): the media fallback comes back where the refused item was placed (#98) The #98 media fallback replayed the start position of the session's first mount. The #93/#65 stage-2 recovery swaps a fresh item in at the position playback held, so when that recovery item was refused at startup, the fallback rewound the session to wherever it had first been loaded. 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 recovery item was refused with -11868, and playback resumed at 1834.79 s. A title started from its beginning resumes at its first frame. NativeAVPlayerHost now records where each mount places its item, in-place swaps included, and the fallback reads that instead of the engine's first-mount copy. The first mount records the same value as before, so a fallback of a first mount is unchanged. (cherry picked from commit f36fe40f0a4c6db45acb4d982d281e8938448424) --- CHANGELOG.md | 1 + .../AetherEngine/AetherEngine+Loading.swift | 1 - Sources/AetherEngine/AetherEngine.swift | 10 ++- .../Native/NativeAVPlayerHost.swift | 8 +++ .../MasterFallbackDecisionTests.swift | 63 +++++++++++++++++++ docs/formats.md | 2 +- 6 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee23e99f7..cf1f90a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,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. +- 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. - 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 8644beded..f79aae82e 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 811317357..993a34639 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,8 +2475,10 @@ 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 EngineLog.emit( "[AetherEngine] AVPlayer rejected the master (code=\(rejection.code)); falling back to " + "media playlist (no CC/subtitle renditions) at " diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index f07064031..db8c84251 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 @@ -450,6 +457,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 diff --git a/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift b/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift index e7fda8a50..069769e4c 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,65 @@ 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")) + } +} diff --git a/docs/formats.md b/docs/formats.md index 7e42a4a8b..fae44f750 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -363,7 +363,7 @@ Whether an HDR or Dolby Vision master is accepted is the receiver's decision, an The refusal is silent, so it has to be caught by watching progress: no `-11868`, no failed item, the rate flickers to `playing` for a single tick so even `hasEverPlayed` latches, and the picture never starts while AVKit shows its "not playable on this display" sign. Five seconds without a segment fetched on a master handed to a receiver reloads the LAN media playlist, which every receiver takes, and that receiver is remembered by route UID for the rest of the process so it goes straight to media from then on. A second master attempt, to exploit the output switch the first one triggers, was tried on device and only doubled the wait. For subtitles on HDR content over AirPlay the answer is the receiver's setting: fix its video format to HDR or Dolby Vision. `$nativeSubtitleRenditionsServed` reports which playlist is actually in use, so a host can tell the user their subtitles will not travel to this route rather than dropping them silently. A wired HDMI external display is a different route and keeps the loopback plus its master. -**Master-rejection fallback (#98, #130).** When AVPlayer rejects the served master (`-11868` AVErrorNoCompatibleAlternatesForExternalDisplay, `-11848` for an SDR-parked panel, or `-1002` when every variant was filtered at master parse time), the engine reloads the bare media playlist in place; a live session rejoins at the edge instead of replaying its stale start position. HDR / DV on an SDR external display is therefore media-playlist-driven (an AVKit limitation: forcing `VIDEO-RANGE=SDR` does not fool the external-display compatibility gate, which checks the real `colr` / codec rather than the manifest string), so the `SUBTITLES` renditions do not travel there. The separate `#35` cold-DV-start readiness gate, whose scenario is an HDR TV, first tries an HDR-preserving reduced master (`SUPPLEMENTAL-CODECS` dropped so it is plain HDR10, source range and `SUBTITLES` group kept) before the bare media playlist, so a cold DV start keeps HDR10 plus subtitles instead of dropping straight to subtitle-less media. +**Master-rejection fallback (#98, #130).** When AVPlayer rejects the served master (`-11868` AVErrorNoCompatibleAlternatesForExternalDisplay, `-11848` for an SDR-parked panel, or `-1002` when every variant was filtered at master parse time), the engine reloads the bare media playlist in place; a VOD session comes back where the refused item was placed (after a stage-2 recovery reload, that is the recovered position, not the session's first start), and a live session rejoins at the edge instead of replaying its stale start position. HDR / DV on an SDR external display is therefore media-playlist-driven (an AVKit limitation: forcing `VIDEO-RANGE=SDR` does not fool the external-display compatibility gate, which checks the real `colr` / codec rather than the manifest string), so the `SUBTITLES` renditions do not travel there. The separate `#35` cold-DV-start readiness gate, whose scenario is an HDR TV, first tries an HDR-preserving reduced master (`SUPPLEMENTAL-CODECS` dropped so it is plain HDR10, source range and `SUBTITLES` group kept) before the bare media playlist, so a cold DV start keeps HDR10 plus subtitles instead of dropping straight to subtitle-less media. **Rich ASS styling.** With `LoadOptions.preserveASSMarkup` the tap keeps raw ASS event lines so the host overlay renders full styling (positions, colours); the WebVTT renditions strip the markup at serve time, so PiP shows plain text in the system caption style. From 5e7cec9c1a343496667559be92e1423f0fbe9e60 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:55:44 -0400 Subject: [PATCH 2/4] fix(native): the media fallback plays only for a viewer who was playing (#98) When a dead item's recovery reload was refused (-11868), fallBackToMediaPlaylist swapped in the media playlist and called play() unconditionally, so a title paused behind the tvOS screensaver started itself. The item-death reload now keeps the viewer's pause (clearing the host's play intent), and the fallback reads that same intent before it plays. Ports the fallback half of upstream superuser404notfound/AetherEngine#623; the reload half is covered by this fork's own item-death change. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + Sources/AetherEngine/AetherEngine.swift | 4 +++- .../MasterFallbackDecisionTests.swift | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1f90a7a..f320ac45a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ the public-API contract. - 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 host's play intent says the viewer was playing. Upstream #623. - 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.swift b/Sources/AetherEngine/AetherEngine.swift index 993a34639..2370dec74 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2487,7 +2487,9 @@ public final class AetherEngine: ObservableObject { host.swapItem(url: fallbackURL, startPosition: isLive ? nil : position, skipInitialSeek: LiveReloadPolicy.skipInitialSeek(isLive: isLive, isRejoin: true)) - host.play() + // Resume only a viewer who was playing; the host's intent (#122) survives the in-place swap. + // A paused title refused behind the tvOS screensaver used to start itself and wake it. + if host.transportIntentIsPlaying { host.play() } } /// #35 readiness-gate settle windows. Generous enough that a slow-but-healthy cold start reads as diff --git a/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift b/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift index 069769e4c..70cbe30bd 100644 --- a/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift +++ b/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift @@ -114,3 +114,27 @@ struct MasterFallbackPositionTests { #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, read from the host's durable intent (#122), which the +/// in-place swap keeps: the stage-2 reload leaves it cleared for a viewer who paused. +@Suite("#98: the media fallback resumes only a viewer who was playing") +@MainActor +struct MasterFallbackTransportTests { + /// 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 asks the host's play intent before it plays") + func fallbackAsksTheIntent() 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.. Date: Tue, 29 Sep 2026 10:14:05 -0400 Subject: [PATCH 3/4] fix(native): count a Play from AVKit when the media fallback decides to resume The fallback read only the host's play intent, which a Play from AVKit, Control Center or PiP never sets. A viewer who started an autoplay=false mount from the transport bar, or pressed Play from AVKit after a paused item-death reload, got a paused fallback. The host now decides at the refusal: the item plays on unless the viewer paused it before the refusal (the item-death one-second margin), and only if the engine's intent or AVPlayer's rate says it was told to play. When it stays paused the fallback calls pause() so a latched intent cannot restart the fresh item on readyToPlay. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- Sources/AetherEngine/AetherEngine.swift | 16 ++++++-- .../Native/MasterFallbackDecision.swift | 3 ++ .../Native/NativeAVPlayerHost.swift | 30 ++++++++++++-- .../MasterFallbackDecisionTests.swift | 41 ++++++++++++++++--- 5 files changed, 79 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f320ac45a..15a8cc03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ the public-API contract. - 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 host's play intent says the viewer was playing. Upstream #623. +- 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.swift b/Sources/AetherEngine/AetherEngine.swift index 2370dec74..77acf01ad 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2482,14 +2482,22 @@ public final class AetherEngine: ObservableObject { 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") + + (rejection.resumesPlaying ? "" : ", staying paused for the viewer"), category: .session) host.swapItem(url: fallbackURL, startPosition: isLive ? nil : position, skipInitialSeek: LiveReloadPolicy.skipInitialSeek(isLive: isLive, isRejoin: true)) - // Resume only a viewer who was playing; the host's intent (#122) survives the in-place swap. - // A paused title refused behind the tvOS screensaver used to start itself and wake it. - if host.transportIntentIsPlaying { host.play() } + // Resume only a viewer who was playing (decided when the item was refused, 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 rejection.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/MasterFallbackDecision.swift b/Sources/AetherEngine/Native/MasterFallbackDecision.swift index 96a0246cd..1ae8c47bd 100644 --- a/Sources/AetherEngine/Native/MasterFallbackDecision.swift +++ b/Sources/AetherEngine/Native/MasterFallbackDecision.swift @@ -10,6 +10,9 @@ struct DisplayRejection: Sendable, Equatable { /// `NSError.domain` of the item error behind the rejection: the message is AVFoundation's /// localized text, so the domain is what still classifies once it is published (#376). let domain: String? + /// Whether the media fallback plays: the refused item was playing, or told to play, and the + /// viewer had not paused it. See `NativeAVPlayerHost.mediaFallbackResumesPlaying`. + var resumesPlaying: Bool = true } /// Pure master to media fallback decision (#98). Kept separate and pure so the gate is testable diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index db8c84251..944666c5f 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -150,6 +150,10 @@ 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 on the current item, from any source. 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; a fresh load starts stopped. + private var transportRolledSinceLoad = false /// 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 @@ -668,6 +672,7 @@ final class NativeAVPlayerHost { Task { @MainActor in guard let self, self.sessionID == sid else { return } self.rate = rate + if rate != 0 { self.transportRolledSinceLoad = 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. @@ -974,6 +979,17 @@ final class NativeAVPlayerHost { return transportRolling || !diedUnderPause } + /// Pure decision: does the media fallback play the item that replaces a refused master? 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 the rate for AVKit, + /// Control Center or PiP, which never touch the intent. + nonisolated static func mediaFallbackResumesPlaying( + intentIsPlaying: Bool, transportRolledSinceLoad: Bool, pausedBeforeRejection: Bool + ) -> Bool { + !pausedBeforeRejection && (intentIsPlaying || transportRolledSinceLoad) + } + /// #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. @@ -1045,9 +1061,16 @@ final class NativeAVPlayerHost { "[NativeAVPlayerHost] #\(sessionID) startup .failed is a master rejection " + "(code=\(code)); signalling engine for media fallback instead of surfacing", category: .engine) - pendingDisplayRejection = DisplayRejection(code: code, - message: desc, - domain: (item.error as NSError?)?.domain) + pendingDisplayRejection = DisplayRejection( + code: code, + message: desc, + domain: (item.error as NSError?)?.domain, + resumesPlaying: Self.mediaFallbackResumesPlaying( + intentIsPlaying: playIntent, + transportRolledSinceLoad: transportRolledSinceLoad, + pausedBeforeRejection: Self.transportPausedBeforeFailure( + pausedSinceUptime: pausedSinceUptime, + failureUptime: DispatchTime.now().uptimeNanoseconds))) return } // AE#561: a startup failure on the media itself (a segment Apple's parser refuses) is @@ -2164,6 +2187,7 @@ final class NativeAVPlayerHost { readinessDeadlineSeconds = nil // Re-arm #50 hasEverPlayed: reused host must not inherit prior session's established state. hasEverPlayed = false + transportRolledSinceLoad = inPlaceSwap && avPlayer.rate != 0 // #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 70cbe30bd..8478b47d7 100644 --- a/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift +++ b/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift @@ -116,15 +116,46 @@ struct MasterFallbackPositionTests { } /// #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, read from the host's durable intent (#122), which the -/// in-place swap keeps: the stage-2 reload leaves it cleared for a viewer who paused. +/// 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 { + @Test("An item the engine was playing is replaced playing") + func enginePlayResumes() { + #expect(NativeAVPlayerHost.mediaFallbackResumesPlaying( + intentIsPlaying: true, transportRolledSinceLoad: false, pausedBeforeRejection: false)) + } + + @Test("A Play from AVKit, Control Center or PiP, which leaves the intent clear, is kept") + func externalPlayResumes() { + #expect(NativeAVPlayerHost.mediaFallbackResumesPlaying( + intentIsPlaying: false, transportRolledSinceLoad: true, pausedBeforeRejection: false)) + } + + @Test("A viewer who paused before the refusal stays paused, whichever way the pause came") + func pauseBeforeRejectionStaysPaused() { + #expect(!NativeAVPlayerHost.mediaFallbackResumesPlaying( + intentIsPlaying: true, transportRolledSinceLoad: true, pausedBeforeRejection: true)) + #expect(!NativeAVPlayerHost.mediaFallbackResumesPlaying( + intentIsPlaying: false, transportRolledSinceLoad: true, pausedBeforeRejection: true)) + } + + @Test("An item nobody told to play stays paused") + func neverPlayedStaysPaused() { + #expect(!NativeAVPlayerHost.mediaFallbackResumesPlaying( + intentIsPlaying: false, transportRolledSinceLoad: false, pausedBeforeRejection: false)) + } + + @Test("A rejection built without a transport verdict plays, as the fallback always did") + func defaultRejectionResumes() { + #expect(DisplayRejection(code: -11868, message: "", domain: nil).resumesPlaying) + } + /// 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 asks the host's play intent before it plays") - func fallbackAsksTheIntent() throws { + @Test("The media fallback plays only on the rejection's verdict") + func fallbackAsksTheRejection() throws { let source = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() @@ -134,7 +165,7 @@ struct MasterFallbackTransportTests { 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.. Date: Tue, 29 Sep 2026 10:30:30 -0400 Subject: [PATCH 4/4] fix(native): decide the media fallback's transport when it runs, not at the refusal Three races in the previous verdict, raised in review: - An engine pause less than a second before the refusal falls inside the failure margin, and the per-load roll flag still said the item had played, so the fallback resumed a paused title. - The fallback runs a task hop after the refusal; a Play or Pause pressed in between was overwritten by the verdict taken earlier. - The verdict was taken in the status KVO hop, which can run before the rate KVO hop that records a Play from AVKit. The host now records when the master was refused and any engine command since, and the engine asks for the verdict inside fallBackToMediaPlaylist, before the swap. A roll counts only if AVPlayer reported it after the engine's last stop, so an engine pause clears it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/AetherEngine/AetherEngine.swift | 11 +-- .../Native/MasterFallbackDecision.swift | 3 - .../Native/NativeAVPlayerHost.swift | 80 +++++++++++++------ .../MasterFallbackDecisionTests.swift | 60 +++++++++----- 4 files changed, 105 insertions(+), 49 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 77acf01ad..03aecd5f4 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2479,19 +2479,20 @@ public final class AetherEngine: ObservableObject { // 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") - + (rejection.resumesPlaying ? "" : ", staying paused for the viewer"), + + (resumesPlaying ? "" : ", staying paused for the viewer"), category: .session) host.swapItem(url: fallbackURL, startPosition: isLive ? nil : position, skipInitialSeek: LiveReloadPolicy.skipInitialSeek(isLive: isLive, isRejoin: true)) - // Resume only a viewer who was playing (decided when the item was refused, 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 rejection.resumesPlaying { + // 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 diff --git a/Sources/AetherEngine/Native/MasterFallbackDecision.swift b/Sources/AetherEngine/Native/MasterFallbackDecision.swift index 1ae8c47bd..96a0246cd 100644 --- a/Sources/AetherEngine/Native/MasterFallbackDecision.swift +++ b/Sources/AetherEngine/Native/MasterFallbackDecision.swift @@ -10,9 +10,6 @@ struct DisplayRejection: Sendable, Equatable { /// `NSError.domain` of the item error behind the rejection: the message is AVFoundation's /// localized text, so the domain is what still classifies once it is published (#376). let domain: String? - /// Whether the media fallback plays: the refused item was playing, or told to play, and the - /// viewer had not paused it. See `NativeAVPlayerHost.mediaFallbackResumesPlaying`. - var resumesPlaying: Bool = true } /// Pure master to media fallback decision (#98). Kept separate and pure so the gate is testable diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 944666c5f..ec3ebaca7 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -150,10 +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 on the current item, from any source. 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; a fresh load starts stopped. - private var transportRolledSinceLoad = false + /// 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 @@ -672,7 +680,7 @@ final class NativeAVPlayerHost { Task { @MainActor in guard let self, self.sessionID == sid else { return } self.rate = rate - if rate != 0 { self.transportRolledSinceLoad = true } + 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. @@ -979,15 +987,37 @@ final class NativeAVPlayerHost { return transportRolling || !diedUnderPause } - /// Pure decision: does the media fallback play the item that replaces a refused master? 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 the rate for AVKit, - /// Control Center or PiP, which never touch the intent. + /// 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( - intentIsPlaying: Bool, transportRolledSinceLoad: Bool, pausedBeforeRejection: Bool + commandSinceRejection: Bool?, intentIsPlaying: Bool, rolledSinceEngineStop: Bool, + pausedBeforeRejection: Bool ) -> Bool { - !pausedBeforeRejection && (intentIsPlaying || transportRolledSinceLoad) + 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). @@ -1061,16 +1091,11 @@ final class NativeAVPlayerHost { "[NativeAVPlayerHost] #\(sessionID) startup .failed is a master rejection " + "(code=\(code)); signalling engine for media fallback instead of surfacing", category: .engine) - pendingDisplayRejection = DisplayRejection( - code: code, - message: desc, - domain: (item.error as NSError?)?.domain, - resumesPlaying: Self.mediaFallbackResumesPlaying( - intentIsPlaying: playIntent, - transportRolledSinceLoad: transportRolledSinceLoad, - pausedBeforeRejection: Self.transportPausedBeforeFailure( - pausedSinceUptime: pausedSinceUptime, - failureUptime: DispatchTime.now().uptimeNanoseconds))) + displayRejectionUptime = DispatchTime.now().uptimeNanoseconds + transportCommandSinceRejection = nil + pendingDisplayRejection = DisplayRejection(code: code, + message: desc, + domain: (item.error as NSError?)?.domain) return } // AE#561: a startup failure on the media itself (a segment Apple's parser refuses) is @@ -1739,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() @@ -1747,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) @@ -2011,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 @@ -2187,7 +2217,11 @@ final class NativeAVPlayerHost { readinessDeadlineSeconds = nil // Re-arm #50 hasEverPlayed: reused host must not inherit prior session's established state. hasEverPlayed = false - transportRolledSinceLoad = inPlaceSwap && avPlayer.rate != 0 + 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 8478b47d7..b087cb2c0 100644 --- a/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift +++ b/Tests/AetherEngineTests/MasterFallbackDecisionTests.swift @@ -121,41 +121,62 @@ struct MasterFallbackPositionTests { @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(NativeAVPlayerHost.mediaFallbackResumesPlaying( - intentIsPlaying: true, transportRolledSinceLoad: false, pausedBeforeRejection: false)) + #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(NativeAVPlayerHost.mediaFallbackResumesPlaying( - intentIsPlaying: false, transportRolledSinceLoad: true, pausedBeforeRejection: false)) + #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(!NativeAVPlayerHost.mediaFallbackResumesPlaying( - intentIsPlaying: true, transportRolledSinceLoad: true, pausedBeforeRejection: true)) - #expect(!NativeAVPlayerHost.mediaFallbackResumesPlaying( - intentIsPlaying: false, transportRolledSinceLoad: true, pausedBeforeRejection: true)) + #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("An item nobody told to play stays paused") - func neverPlayedStaysPaused() { - #expect(!NativeAVPlayerHost.mediaFallbackResumesPlaying( - intentIsPlaying: false, transportRolledSinceLoad: false, pausedBeforeRejection: 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)) } - @Test("A rejection built without a transport verdict plays, as the fallback always did") - func defaultRejectionResumes() { - #expect(DisplayRejection(code: -11868, message: "", domain: nil).resumesPlaying) + /// 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 rejection's verdict") - func fallbackAsksTheRejection() throws { + @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() @@ -165,7 +186,10 @@ struct MasterFallbackTransportTests { 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..