@npaw/forge-react-native 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +10 -0
  2. package/NPAWForgeReactNative.podspec +44 -0
  3. package/README.md +51 -0
  4. package/android/build.gradle +77 -0
  5. package/android/src/main/AndroidManifest.xml +14 -0
  6. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
  7. package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
  8. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
  9. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
  10. package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
  13. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
  14. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
  15. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  16. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
  17. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
  18. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
  19. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  20. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
  21. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
  22. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
  23. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  24. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
  25. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
  26. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
  27. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  28. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
  29. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
  30. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
  31. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
  32. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
  33. package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
  34. package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
  35. package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
  36. package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
  37. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
  38. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
  39. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
  40. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
  41. package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
  42. package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
  43. package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
  44. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
  45. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
  46. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
  47. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
  48. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
  49. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
  50. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
  51. package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
  52. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
  53. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
  54. package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
  55. package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
  56. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
  57. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
  58. package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
  59. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
  60. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
  61. package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
  62. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
  63. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
  64. package/ios/ForgeModuleImpl.swift +1131 -0
  65. package/ios/NPAWForgeTurboModule.mm +399 -0
  66. package/package.json +33 -0
  67. package/src/ForgeAdReporter.ts +101 -0
  68. package/src/ForgeVideo.ts +169 -0
  69. package/src/NPAWForge.ts +177 -0
  70. package/src/capture.ts +192 -0
  71. package/src/events.ts +40 -0
  72. package/src/index.ts +37 -0
  73. package/src/specs/NativeNPAWForge.ts +168 -0
  74. package/src/types.ts +114 -0
@@ -0,0 +1,877 @@
1
+ /**
2
+ * Copyright (C) NPAW - All Rights Reserved
3
+ *
4
+ * This source code is protected under international copyright law. All rights
5
+ * reserved and protected by the copyright holders.
6
+ * This file is confidential and only available to authorized individuals with the
7
+ * permission of the copyright holders. If you encounter this file and do not have
8
+ * permission, please contact the copyright holders and delete this file.
9
+ *
10
+ *
11
+ * @author: Victor Soto <victor.soto@nicepeopleatwork.com>
12
+ * @description: AVPlayer adapter that converts player observations into typed Forge facts, anchoring start at play intent, join at real playhead advancement, and seek pairs at observed time jumps.
13
+ */
14
+
15
+ import AVFoundation
16
+ import Foundation
17
+ #if canImport(NPAWForgeCore)
18
+ import NPAWForgeCore
19
+ #endif
20
+
21
+ public final class AVPlayerAdapter: PlayerAdapter {
22
+ /// Serializes all mutable adapter state shared between AVFoundation KVO and
23
+ /// notification callbacks (arbitrary threads), the app thread (bind/unbind,
24
+ /// player replacement), the periodic playback tick, and the Rust ping worker
25
+ /// that synchronously pulls metadata snapshots through the content-metadata
26
+ /// provider callback.
27
+ private let stateLock = NSRecursiveLock()
28
+ /// Serializes KVO observation registration/invalidation, notification token
29
+ /// add/remove, and periodic time observer install/removal. Kept separate from
30
+ /// `stateLock` so event emission never holds it, and held across the actual
31
+ /// `observe()`/`invalidate()` calls because concurrent KVO (de)registration
32
+ /// on the same AVPlayer is not safe.
33
+ private let observerLock = NSRecursiveLock()
34
+ private var playerObservations: [NSKeyValueObservation] = []
35
+ private var itemObservations: [NSKeyValueObservation] = []
36
+ private var notificationTokens: [NSObjectProtocol] = []
37
+ /// Periodic playback tick used for join progression and seek detection. The
38
+ /// owner is remembered so removal targets the player the observer was
39
+ /// installed on even after `player` is replaced.
40
+ private var timeObserverToken: Any?
41
+ private var timeObserverOwner: AVPlayer?
42
+ private var started = false
43
+ private var joined = false
44
+ private var buffering = false
45
+ private var seeking = false
46
+ /// Whether the last observed transport intent was a user pause; the
47
+ /// paused → waitingToPlayAtSpecifiedRate resume spin-up must not open a
48
+ /// buffer window (OLD plugin-ios reports no rebuffer on resume).
49
+ private var paused = false
50
+ /// Playhead baseline captured at start intent; join fires only once the
51
+ /// playhead advances beyond it (OLD plugin-ios join semantics).
52
+ private var joinBaselinePlayheadMs: Int64?
53
+ /// Last playback-tick playhead sample used for seek jump detection.
54
+ private var lastTickPlayheadMs: Int64?
55
+ /// Last observed non-zero playback rate; paused ticks reuse it so rate
56
+ /// changes never register as seeks (OLD plugin-ios rule).
57
+ private var lastTickRate: Double = 1
58
+ /// Monotonic anchor of the in-flight seek (buffer begin when the seek
59
+ /// converted an active buffer), used for the explicit seek duration.
60
+ private var seekAnchorElapsedMs: Int64?
61
+ private var bufferBeginElapsedMs: Int64?
62
+ /// Incremented on every unregister so in-flight observer rebuilds racing an
63
+ /// unregister cannot resurrect stale observation entries.
64
+ private var observationGeneration = 0
65
+ /// Remote behavior hints snapshotted at view start (`registerListeners`),
66
+ /// never re-read mid-view. `appliedJoinMethod` is the resolved token
67
+ /// (`ph` or `rd`); `ff`/`auto`/unknown resolve to the playhead join.
68
+ private var appliedJoinMethod = AVPlayerAdapter.joinMethodPlayhead
69
+ private var appliedSeekHeuristicEnabled = true
70
+ private var appliedSeekJumpThresholdMs = AVPlayerAdapter.seekDetectionThresholdMs
71
+
72
+ /// Last known-good playback facts derived from the item's access log, all
73
+ /// guarded by `stateLock` and cleared per view in `resetPlaybackStateLocked`
74
+ /// so nothing leaks across items.
75
+ ///
76
+ /// Retention matters: AVFoundation reports every access-log metric as
77
+ /// negative or absent when unknown, and an event logged right after a seek
78
+ /// commonly reports nothing at all. Without retention those pings published
79
+ /// `bitrate=0` mid-view. Unknown must stay unknown (kernel default) or keep
80
+ /// the last real value — never collapse to zero.
81
+ private var lastKnownBitrate: Int64?
82
+ private var lastKnownThroughput: Double?
83
+ private var lastKnownFps: Double?
84
+ /// Retained as a running maximum: dropped frames and transferred bytes are
85
+ /// cumulative counters and must never decrease within a view.
86
+ private var lastKnownDroppedFrames: Int64?
87
+ private var lastKnownTotalBytes: Int64?
88
+ /// Elapsed-time stamp of the last access-log read. `snapshotMetadata` and
89
+ /// `snapshotQos` are invoked back-to-back with identical values by
90
+ /// `VideoAdapter.metadataSnapshot`, so this collapses them into one read.
91
+ private var lastFactsSampleElapsedMs: Int64?
92
+
93
+ /// Playback tick cadence for join progression and seek detection.
94
+ static let playbackTickIntervalSeconds: Double = 0.25
95
+ /// Minimum playhead advancement beyond the start baseline treated as real
96
+ /// playback progress (OLD plugin-ios epsilon).
97
+ static let joinAdvanceEpsilonMs: Int64 = 50
98
+ /// Playhead jump between consecutive ticks treated as a seek while playing
99
+ /// at 1x (OLD plugin-ios threshold; scaled by the observed playback rate).
100
+ static let seekDetectionThresholdMs: Int64 = 2_000
101
+ /// Seek jump threshold while buffering; higher so the playhead catch-up
102
+ /// after a stall never registers as a seek (OLD plugin-ios rule).
103
+ static let seekBufferingDetectionThresholdMs: Int64 = 10_000
104
+ /// Smallest reported seek duration (OLD plugin-ios clamps to 1 ms); also
105
+ /// keeps the kernel from deriving a -1 sentinel for zero-width pairs.
106
+ static let minimumSeekDurationMs: Int64 = 1
107
+ /// Canonical join-method token for the playhead-advance tick join.
108
+ static let joinMethodPlayhead = "ph"
109
+ /// Canonical join-method token for the ready-fallback join.
110
+ static let joinMethodReady = "rd"
111
+
112
+ public init(player: AVPlayer? = nil) {
113
+ super.init(player: player)
114
+ descriptor = AdapterDescriptor(
115
+ adapterId: "avplayer",
116
+ category: .player,
117
+ playerFamily: "AVPlayer",
118
+ version: "avfoundation"
119
+ )
120
+ }
121
+
122
+ public var player: AVPlayer? {
123
+ get { playerObject as? AVPlayer }
124
+ set { playerObject = newValue }
125
+ }
126
+
127
+ /// AVPlayer reports duration/liveness/rendition only after the HLS playlist
128
+ /// is parsed, so `/start` waits for those facts through the kernel
129
+ /// `waitForMetadata` gate by default. An explicit
130
+ /// `VideoOptions.waitForMetadata(false)` always wins.
131
+ public override var defaultWaitForMetadata: Bool { true }
132
+
133
+ public override func registerListeners() {
134
+ guard let player else { return }
135
+ observerLock.lock()
136
+ defer { observerLock.unlock() }
137
+ unregisterListeners()
138
+ // Remote behavior hints apply at view start only, never mid-view.
139
+ applyBehaviorHints(capturePlanBehaviorHints)
140
+ _ = fireInit(metadata: snapshotMetadata())
141
+ observePlayer(player)
142
+ observeItem(player.currentItem)
143
+ installTimeObserver(player)
144
+ handleTimeControlStatus(player.timeControlStatus)
145
+ }
146
+
147
+ public override func unregisterListeners() {
148
+ observerLock.lock()
149
+ defer { observerLock.unlock() }
150
+ removeTimeObserverLocked()
151
+ stateLock.lock()
152
+ observationGeneration += 1
153
+ let playerObservationsToInvalidate = playerObservations
154
+ let itemObservationsToInvalidate = itemObservations
155
+ let tokensToRemove = notificationTokens
156
+ playerObservations.removeAll()
157
+ itemObservations.removeAll()
158
+ notificationTokens.removeAll()
159
+ resetPlaybackStateLocked()
160
+ stateLock.unlock()
161
+ playerObservationsToInvalidate.forEach { $0.invalidate() }
162
+ itemObservationsToInvalidate.forEach { $0.invalidate() }
163
+ tokensToRemove.forEach(NotificationCenter.default.removeObserver)
164
+ }
165
+
166
+ public override func snapshotMetadata(
167
+ timemarkMs: Int64 = ForgeClock.nowMs(),
168
+ elapsedRealtimeMs: Int64 = ForgeClock.elapsedRealtimeMs()
169
+ ) -> PlayerMetadataSnapshot {
170
+ refreshPlaybackFactsFromPlayer(elapsedRealtimeMs: elapsedRealtimeMs)
171
+ var snapshot = PlayerMetadataSnapshot(
172
+ playerName: descriptor.playerFamily,
173
+ playerVersion: descriptor.version,
174
+ adapterVersion: descriptor.version,
175
+ resourceUrl: resourceUrl()?.absoluteString,
176
+ contentType: inferredContentType(),
177
+ playheadMs: currentPlayheadMs(),
178
+ durationMs: currentDurationMs(),
179
+ bitrate: currentBitrate(),
180
+ rendition: currentRendition(),
181
+ containerFormat: resourceUrl()?.pathExtension
182
+ )
183
+ if let error = player?.currentItem?.error ?? player?.error {
184
+ snapshot.errorCode = String((error as NSError).code)
185
+ snapshot.errorMessage = error.localizedDescription
186
+ snapshot.errorMetadata = (error as NSError).domain
187
+ }
188
+ return snapshot
189
+ }
190
+
191
+ /// QoS derived from the item's access log. `appleContentMetadata` hardcodes
192
+ /// throughput, totalBytes and droppedFrames to nil, so this override is the
193
+ /// only route by which those facts can reach the kernel on Apple — without
194
+ /// it the kernel emits its `-1`/`0` defaults.
195
+ ///
196
+ /// `throughput` carries `observedBitrate`, which is exactly the metric that
197
+ /// was previously mislabelled as `bitrate`. `latency` is deliberately
198
+ /// omitted: v7 gates it behind a configuration option that does not exist
199
+ /// on Apple `VideoOptions`, so adding it would mean new public API.
200
+ /// Returns nil when nothing is known so the kernel keeps its own defaults.
201
+ public override func snapshotQos(
202
+ timemarkMs: Int64 = ForgeClock.nowMs(),
203
+ elapsedRealtimeMs: Int64 = ForgeClock.elapsedRealtimeMs()
204
+ ) -> QosSnapshot? {
205
+ refreshPlaybackFactsFromPlayer(elapsedRealtimeMs: elapsedRealtimeMs)
206
+ let currentRate = player?.rate
207
+ stateLock.lock()
208
+ let throughput = lastKnownThroughput
209
+ let fps = lastKnownFps
210
+ let droppedFrames = lastKnownDroppedFrames
211
+ let totalBytes = lastKnownTotalBytes
212
+ let pausedNow = paused
213
+ stateLock.unlock()
214
+
215
+ var playrate: Double?
216
+ if pausedNow {
217
+ playrate = 0.0
218
+ } else if let currentRate, currentRate > 0 {
219
+ playrate = Double(currentRate)
220
+ }
221
+
222
+ if throughput == nil, fps == nil, droppedFrames == nil, totalBytes == nil, playrate == nil {
223
+ return nil
224
+ }
225
+ return QosSnapshot(
226
+ throughput: throughput,
227
+ fps: fps,
228
+ latency: nil,
229
+ playrate: playrate,
230
+ droppedFrames: droppedFrames,
231
+ totalBytes: totalBytes
232
+ )
233
+ }
234
+
235
+ private func observePlayer(_ player: AVPlayer) {
236
+ observerLock.lock()
237
+ defer { observerLock.unlock() }
238
+ let generation = currentObservationGeneration()
239
+ var observations: [NSKeyValueObservation] = []
240
+ observations.append(player.observe(\.timeControlStatus, options: [.new, .initial]) { [weak self] player, _ in
241
+ self?.handleTimeControlStatus(player.timeControlStatus)
242
+ })
243
+ observations.append(player.observe(\.rate, options: [.new]) { [weak self] player, _ in
244
+ self?.handleRate(player.rate)
245
+ })
246
+ observations.append(player.observe(\.currentItem, options: [.new]) { [weak self] player, _ in
247
+ self?.handleItemTransition(player)
248
+ })
249
+ stateLock.lock()
250
+ guard generation == observationGeneration else {
251
+ stateLock.unlock()
252
+ observations.forEach { $0.invalidate() }
253
+ return
254
+ }
255
+ playerObservations.append(contentsOf: observations)
256
+ stateLock.unlock()
257
+ }
258
+
259
+ private func observeItem(_ item: AVPlayerItem?) {
260
+ observerLock.lock()
261
+ defer { observerLock.unlock() }
262
+ stateLock.lock()
263
+ let generation = observationGeneration
264
+ let staleObservations = itemObservations
265
+ let staleTokens = notificationTokens
266
+ itemObservations.removeAll()
267
+ notificationTokens.removeAll()
268
+ stateLock.unlock()
269
+ staleObservations.forEach { $0.invalidate() }
270
+ staleTokens.forEach(NotificationCenter.default.removeObserver)
271
+ guard let item else { return }
272
+ var observations: [NSKeyValueObservation] = []
273
+ var tokens: [NSObjectProtocol] = []
274
+ observations.append(item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in
275
+ self?.handleItemStatus(item.status, error: item.error)
276
+ })
277
+ observations.append(item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in
278
+ if item.isPlaybackLikelyToKeepUp {
279
+ self?.handlePlaybackLikelyToKeepUp()
280
+ }
281
+ })
282
+ tokens.append(NotificationCenter.default.addObserver(
283
+ forName: .AVPlayerItemDidPlayToEndTime,
284
+ object: item,
285
+ queue: nil
286
+ ) { [weak self] _ in
287
+ self?.handlePlaybackEnded()
288
+ })
289
+ tokens.append(NotificationCenter.default.addObserver(
290
+ forName: .AVPlayerItemFailedToPlayToEndTime,
291
+ object: item,
292
+ queue: nil
293
+ ) { [weak self] notification in
294
+ let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
295
+ self?.handlePlaybackFailed(error: error)
296
+ })
297
+ stateLock.lock()
298
+ guard generation == observationGeneration else {
299
+ stateLock.unlock()
300
+ observations.forEach { $0.invalidate() }
301
+ tokens.forEach(NotificationCenter.default.removeObserver)
302
+ return
303
+ }
304
+ itemObservations.append(contentsOf: observations)
305
+ notificationTokens.append(contentsOf: tokens)
306
+ stateLock.unlock()
307
+ }
308
+
309
+ private func installTimeObserver(_ player: AVPlayer) {
310
+ observerLock.lock()
311
+ defer { observerLock.unlock() }
312
+ removeTimeObserverLocked()
313
+ let generation = currentObservationGeneration()
314
+ let interval = CMTime(
315
+ seconds: Self.playbackTickIntervalSeconds,
316
+ preferredTimescale: CMTimeScale(NSEC_PER_SEC)
317
+ )
318
+ // Periodic observers also fire on time jumps and playback start/stop
319
+ // boundaries, so tick samples bracket pauses and seeks precisely.
320
+ let token = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self, weak player] _ in
321
+ guard let self, let player else { return }
322
+ self.processPlaybackTick(
323
+ playheadMs: self.currentPlayheadMs(),
324
+ sampleElapsedMs: ForgeClock.elapsedRealtimeMs(),
325
+ itemReady: player.currentItem?.status == .readyToPlay,
326
+ isPlaying: player.timeControlStatus == .playing,
327
+ rate: Double(player.rate)
328
+ )
329
+ }
330
+ stateLock.lock()
331
+ guard generation == observationGeneration else {
332
+ stateLock.unlock()
333
+ player.removeTimeObserver(token)
334
+ return
335
+ }
336
+ stateLock.unlock()
337
+ timeObserverToken = token
338
+ timeObserverOwner = player
339
+ }
340
+
341
+ private func removeTimeObserverLocked() {
342
+ if let token = timeObserverToken, let owner = timeObserverOwner {
343
+ owner.removeTimeObserver(token)
344
+ }
345
+ timeObserverToken = nil
346
+ timeObserverOwner = nil
347
+ }
348
+
349
+ /// Item finished playing. Forward the terminal stop fact, then reset playback
350
+ /// flags so a replay or the next queue item starts a fresh view.
351
+ func handlePlaybackEnded() {
352
+ _ = fireStop(metadata: snapshotMetadata())
353
+ resetPlaybackState()
354
+ }
355
+
356
+ /// Item failed mid-playback. Forward the fatal error fact, then reset playback
357
+ /// flags so a recovered or replaced item starts a fresh view.
358
+ func handlePlaybackFailed(error: NSError?) {
359
+ _ = fireFatalError(
360
+ errorCode: error.map { String($0.code) },
361
+ errorMessage: error?.localizedDescription,
362
+ errorMetadata: error?.domain
363
+ )
364
+ resetPlaybackState()
365
+ }
366
+
367
+ /// AVQueuePlayer auto-advance or replaceCurrentItem: stop the previous view if
368
+ /// it had started, reset playback flags, and conditionally restart for the new
369
+ /// item based on the current time control status.
370
+ func handleItemTransition(_ player: AVPlayer) {
371
+ stateLock.lock()
372
+ let wasStarted = started
373
+ stateLock.unlock()
374
+ if wasStarted {
375
+ _ = fireStop(metadata: snapshotMetadata())
376
+ }
377
+ resetPlaybackState()
378
+ observeItem(player.currentItem)
379
+ _ = fireMetadataUpdate(metadata: snapshotMetadata())
380
+ if player.currentItem != nil {
381
+ handleTimeControlStatus(player.timeControlStatus)
382
+ }
383
+ }
384
+
385
+ /// Snapshots the remote capture-plan behavior hints for this playback
386
+ /// attempt. `ff` (first frame) has no observable AVPlayer signal and is
387
+ /// treated as `auto`; `auto`/absent keep the playhead-advance join.
388
+ /// Internal for regression tests; production reads happen at
389
+ /// `registerListeners` (view start).
390
+ func applyBehaviorHints(_ behavior: ForgeCapturePlan.Behavior?) {
391
+ stateLock.lock()
392
+ appliedJoinMethod = Self.resolveJoinMethod(behavior?.joinMethod)
393
+ appliedSeekHeuristicEnabled = behavior?.seekHeuristicEnabled ?? true
394
+ appliedSeekJumpThresholdMs = max(
395
+ Self.minimumSeekDurationMs,
396
+ behavior?.seekJumpThresholdMs ?? Self.seekDetectionThresholdMs
397
+ )
398
+ stateLock.unlock()
399
+ }
400
+
401
+ /// Resolves the remote join-method token for AVPlayer: `rd` joins at item
402
+ /// readiness, while `ph`, `auto`, absent, and the AVPlayer-unsupported
403
+ /// `ff` keep the playhead-advance tick join.
404
+ static func resolveJoinMethod(_ hint: String?) -> String {
405
+ hint?.lowercased() == joinMethodReady ? joinMethodReady : joinMethodPlayhead
406
+ }
407
+
408
+ private func handleItemStatus(_ status: AVPlayerItem.Status, error: Error?) {
409
+ switch status {
410
+ case .readyToPlay:
411
+ // Duration/liveness/rendition become observable here; the metadata
412
+ // refresh releases a kernel-gated pending `/start`.
413
+ _ = fireMetadataUpdate(metadata: snapshotMetadata())
414
+ // The `rd` join method treats readiness itself as the join signal.
415
+ maybeJoinOnReady()
416
+ case .failed:
417
+ let nsError = error as NSError?
418
+ _ = fireFatalError(
419
+ errorCode: nsError.map { String($0.code) },
420
+ errorMessage: error?.localizedDescription,
421
+ errorMetadata: nsError?.domain
422
+ )
423
+ resetPlaybackState()
424
+ case .unknown:
425
+ break
426
+ @unknown default:
427
+ break
428
+ }
429
+ }
430
+
431
+ private func handleRate(_ rate: Float) {
432
+ if rate > 0 {
433
+ // Direct rate manipulation is also a play intent (OLD plugin-ios
434
+ // fires start from rate observations).
435
+ ensureStarted()
436
+ return
437
+ }
438
+ stateLock.lock()
439
+ let isStarted = started
440
+ stateLock.unlock()
441
+ guard isStarted else { return }
442
+ if player?.timeControlStatus == .paused {
443
+ stateLock.lock()
444
+ paused = true
445
+ stateLock.unlock()
446
+ _ = firePause(metadata: snapshotMetadata())
447
+ }
448
+ }
449
+
450
+ func handleTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
451
+ switch status {
452
+ case .paused:
453
+ stateLock.lock()
454
+ let shouldFirePause = started
455
+ if shouldFirePause {
456
+ paused = true
457
+ }
458
+ stateLock.unlock()
459
+ if shouldFirePause {
460
+ _ = firePause(metadata: snapshotMetadata())
461
+ }
462
+ case .waitingToPlayAtSpecifiedRate:
463
+ // Waiting-to-play is the earliest observable play intent on a cold
464
+ // start; anchoring `/start` here keeps the kernel-computed join
465
+ // time spanning the real startup buffering (OLD plugin-ios join
466
+ // semantics).
467
+ ensureStarted()
468
+ stateLock.lock()
469
+ let resumingFromPause = paused
470
+ stateLock.unlock()
471
+ // A paused → waiting spin-up is the user resuming, not a stall:
472
+ // OLD plugin-ios reports no rebuffer on pause → resume.
473
+ if !resumingFromPause {
474
+ beginBuffering()
475
+ }
476
+ case .playing:
477
+ ensureStarted()
478
+ stateLock.lock()
479
+ let wasJoined = joined
480
+ paused = false
481
+ stateLock.unlock()
482
+ // Playback resumed: an in-flight seek ends before buffer/pause
483
+ // bookkeeping (OLD plugin-ios ordering: seekEnd, then resume).
484
+ finishSeek()
485
+ finishBuffering()
486
+ if wasJoined {
487
+ _ = fireResume(metadata: snapshotMetadata())
488
+ }
489
+ @unknown default:
490
+ break
491
+ }
492
+ }
493
+
494
+ /// Fires `/start` exactly once per playback attempt at the earliest play
495
+ /// intent and anchors the join baseline at the intent playhead. When the
496
+ /// playhead is not observable yet (cold start before the item loads), the
497
+ /// first ready playback tick seeds the baseline instead, so the initial
498
+ /// HLS positioning jump never counts as playback progress. Join itself
499
+ /// waits for real playhead advancement observed by the playback tick.
500
+ func ensureStarted() {
501
+ stateLock.lock()
502
+ let needsStart = !started
503
+ if needsStart {
504
+ started = true
505
+ joinBaselinePlayheadMs = currentPlayheadMs()
506
+ }
507
+ stateLock.unlock()
508
+ if needsStart {
509
+ _ = fireStart(metadata: snapshotMetadata())
510
+ }
511
+ }
512
+
513
+ /// Handles one playback tick sample: join progression first, then seek jump
514
+ /// detection. Internal for regression tests; production ticks come from the
515
+ /// periodic time observer, which also fires on time jumps and playback
516
+ /// start/stop boundaries.
517
+ func processPlaybackTick(
518
+ playheadMs: Int64?,
519
+ sampleElapsedMs: Int64,
520
+ itemReady: Bool,
521
+ isPlaying: Bool,
522
+ rate: Double
523
+ ) {
524
+ maybeJoinOnPlayheadAdvance(playheadMs: playheadMs, itemReady: itemReady)
525
+ detectSeekFromTick(
526
+ playheadMs: playheadMs,
527
+ sampleElapsedMs: sampleElapsedMs,
528
+ isPlaying: isPlaying,
529
+ rate: rate
530
+ )
531
+ }
532
+
533
+ /// Join fires only when the item is ready and the playhead advanced beyond
534
+ /// the start baseline, so the kernel-computed join time measures real
535
+ /// startup instead of the readyToPlay/rate-change intent (OLD plugin-ios
536
+ /// join semantics). Backward playhead moves re-anchor the baseline because
537
+ /// AVPlayer can transiently expose the previous item's playhead right
538
+ /// after replaceCurrentItem.
539
+ private func maybeJoinOnPlayheadAdvance(playheadMs: Int64?, itemReady: Bool) {
540
+ stateLock.lock()
541
+ guard started, !joined, itemReady else {
542
+ stateLock.unlock()
543
+ return
544
+ }
545
+ if appliedJoinMethod == Self.joinMethodReady {
546
+ // Ready-fallback join: readiness observed by this tick is the
547
+ // join signal; no playhead advancement is required.
548
+ joined = true
549
+ if let playheadMs {
550
+ lastTickPlayheadMs = playheadMs
551
+ }
552
+ stateLock.unlock()
553
+ _ = fireJoin(metadata: snapshotMetadata())
554
+ return
555
+ }
556
+ guard let playheadMs else {
557
+ stateLock.unlock()
558
+ return
559
+ }
560
+ guard let baseline = joinBaselinePlayheadMs else {
561
+ joinBaselinePlayheadMs = playheadMs
562
+ stateLock.unlock()
563
+ return
564
+ }
565
+ if playheadMs + Self.joinAdvanceEpsilonMs < baseline {
566
+ joinBaselinePlayheadMs = playheadMs
567
+ stateLock.unlock()
568
+ return
569
+ }
570
+ guard playheadMs > baseline + Self.joinAdvanceEpsilonMs else {
571
+ stateLock.unlock()
572
+ return
573
+ }
574
+ joined = true
575
+ // Seed the seek sampler from the join playhead so the first joined tick
576
+ // never compares against a stale pre-join sample.
577
+ lastTickPlayheadMs = playheadMs
578
+ stateLock.unlock()
579
+ _ = fireJoin(metadata: snapshotMetadata())
580
+ }
581
+
582
+ /// Ready-fallback (`rd`) join from the item-status observation: readiness
583
+ /// itself is the join signal once `/start` fired. The playhead-tick path
584
+ /// keeps a mirrored branch so ready-before-start orderings still join on
585
+ /// the next ready tick.
586
+ private func maybeJoinOnReady() {
587
+ stateLock.lock()
588
+ guard appliedJoinMethod == Self.joinMethodReady, started, !joined else {
589
+ stateLock.unlock()
590
+ return
591
+ }
592
+ joined = true
593
+ if let playheadMs = currentPlayheadMs() {
594
+ lastTickPlayheadMs = playheadMs
595
+ }
596
+ stateLock.unlock()
597
+ _ = fireJoin(metadata: snapshotMetadata())
598
+ }
599
+
600
+ /// Seek detection between consecutive playback ticks (OLD plugin-ios
601
+ /// heuristics): detection is suppressed until content join, ticks spanning a
602
+ /// playback-rate change are ignored, the jump threshold scales with the
603
+ /// observed rate and rises while buffering, and an already-active seek is
604
+ /// never re-opened. A seek ends when a later tick observes active playback.
605
+ /// The remote behavior hints can disable the heuristic or replace the base
606
+ /// jump threshold for the current view.
607
+ private func detectSeekFromTick(
608
+ playheadMs: Int64?,
609
+ sampleElapsedMs: Int64,
610
+ isPlaying: Bool,
611
+ rate: Double
612
+ ) {
613
+ var justBeganSeek = false
614
+ var shouldEndSeek = false
615
+ stateLock.lock()
616
+ guard joined, appliedSeekHeuristicEnabled, let playheadMs else {
617
+ stateLock.unlock()
618
+ return
619
+ }
620
+ let baseThresholdMs = appliedSeekJumpThresholdMs
621
+ let currentRate = rate != 0 ? rate : lastTickRate
622
+ if let previousPlayheadMs = lastTickPlayheadMs {
623
+ if currentRate == lastTickRate {
624
+ let rateScaledThresholdMs = Int64((Double(baseThresholdMs) * abs(lastTickRate)).rounded())
625
+ let thresholdMs = buffering
626
+ ? max(Self.seekBufferingDetectionThresholdMs, baseThresholdMs)
627
+ : max(baseThresholdMs, rateScaledThresholdMs)
628
+ if abs(playheadMs - previousPlayheadMs) >= thresholdMs, !seeking {
629
+ seeking = true
630
+ justBeganSeek = true
631
+ // An active buffer folds into the seek (the kernel converts
632
+ // the pair); anchor the duration at the buffer begin so the
633
+ // reported span matches OLD attribution.
634
+ seekAnchorElapsedMs = buffering ? (bufferBeginElapsedMs ?? sampleElapsedMs) : sampleElapsedMs
635
+ if buffering {
636
+ buffering = false
637
+ bufferBeginElapsedMs = nil
638
+ }
639
+ }
640
+ }
641
+ if seeking, !justBeganSeek, isPlaying, rate > 0 {
642
+ shouldEndSeek = true
643
+ }
644
+ }
645
+ lastTickPlayheadMs = playheadMs
646
+ if currentRate != 0 {
647
+ lastTickRate = currentRate
648
+ }
649
+ stateLock.unlock()
650
+ if justBeganSeek {
651
+ _ = fireSeekBegin(metadata: snapshotMetadata())
652
+ }
653
+ if shouldEndSeek {
654
+ finishSeek(sampleElapsedMs: sampleElapsedMs)
655
+ }
656
+ }
657
+
658
+ /// Closes an in-flight seek with an explicit, always-positive duration.
659
+ /// OLD plugin-ios clamps seek durations to at least 1 ms; the explicit
660
+ /// value also keeps the kernel from deriving a -1 sentinel when the pair
661
+ /// would otherwise resolve at-or-before its begin mark.
662
+ func finishSeek(sampleElapsedMs: Int64 = ForgeClock.elapsedRealtimeMs()) {
663
+ stateLock.lock()
664
+ guard seeking else {
665
+ stateLock.unlock()
666
+ return
667
+ }
668
+ seeking = false
669
+ let anchorElapsedMs = seekAnchorElapsedMs
670
+ seekAnchorElapsedMs = nil
671
+ stateLock.unlock()
672
+ var snapshot = snapshotMetadata()
673
+ let spanMs = anchorElapsedMs.map { sampleElapsedMs - $0 } ?? 0
674
+ snapshot.extraParams["seekDurationMs"] = String(max(Self.minimumSeekDurationMs, spanMs))
675
+ _ = fireSeekEnd(metadata: snapshot)
676
+ }
677
+
678
+ /// Enough media is buffered to resume. While a seek is active and playback
679
+ /// has not resumed on its own (paused seek), this is the seek completion
680
+ /// signal; otherwise it closes a plain rebuffer.
681
+ func handlePlaybackLikelyToKeepUp() {
682
+ stateLock.lock()
683
+ let seekWasActive = seeking
684
+ stateLock.unlock()
685
+ if seekWasActive {
686
+ finishSeek()
687
+ } else {
688
+ finishBuffering()
689
+ }
690
+ }
691
+
692
+ private func beginBuffering() {
693
+ stateLock.lock()
694
+ // Rebuffering while a seek is active folds into the seek duration
695
+ // (OLD plugin-ios attribution); the kernel drops the pair anyway.
696
+ guard !seeking else {
697
+ stateLock.unlock()
698
+ return
699
+ }
700
+ let shouldFire = !buffering
701
+ buffering = true
702
+ if shouldFire {
703
+ bufferBeginElapsedMs = ForgeClock.elapsedRealtimeMs()
704
+ }
705
+ stateLock.unlock()
706
+ if shouldFire {
707
+ _ = fireBufferBegin(metadata: snapshotMetadata())
708
+ }
709
+ }
710
+
711
+ private func finishBuffering() {
712
+ stateLock.lock()
713
+ let shouldFire = buffering
714
+ buffering = false
715
+ bufferBeginElapsedMs = nil
716
+ stateLock.unlock()
717
+ if shouldFire {
718
+ _ = fireBufferEnd(metadata: snapshotMetadata())
719
+ }
720
+ }
721
+
722
+ private func resetPlaybackState() {
723
+ stateLock.lock()
724
+ resetPlaybackStateLocked()
725
+ stateLock.unlock()
726
+ }
727
+
728
+ private func resetPlaybackStateLocked() {
729
+ started = false
730
+ joined = false
731
+ buffering = false
732
+ seeking = false
733
+ paused = false
734
+ joinBaselinePlayheadMs = nil
735
+ lastTickPlayheadMs = nil
736
+ lastTickRate = 1
737
+ seekAnchorElapsedMs = nil
738
+ bufferBeginElapsedMs = nil
739
+ lastKnownBitrate = nil
740
+ lastKnownThroughput = nil
741
+ lastKnownFps = nil
742
+ lastKnownDroppedFrames = nil
743
+ lastKnownTotalBytes = nil
744
+ lastFactsSampleElapsedMs = nil
745
+ }
746
+
747
+ private func currentObservationGeneration() -> Int {
748
+ stateLock.lock()
749
+ defer { stateLock.unlock() }
750
+ return observationGeneration
751
+ }
752
+
753
+ private func resourceUrl() -> URL? {
754
+ (player?.currentItem?.asset as? AVURLAsset)?.url
755
+ }
756
+
757
+ /// Classifies live/VOD from the observable AVPlayerItem facts. Internal for
758
+ /// regression tests: an indefinite duration means live only once the item is
759
+ /// ready, because HLS duration stays indefinite until the playlist is parsed
760
+ /// and claiming live before readyToPlay mislabels VOD on `/init`.
761
+ static func classifyContentType(
762
+ durationIsNumeric: Bool,
763
+ durationIsIndefinite: Bool,
764
+ itemReady: Bool
765
+ ) -> ContentType {
766
+ if durationIsNumeric { return .vod }
767
+ guard itemReady else { return .unknown }
768
+ return durationIsIndefinite ? .live : .unknown
769
+ }
770
+
771
+ private func inferredContentType() -> ContentType {
772
+ guard let item = player?.currentItem else { return .unknown }
773
+ let duration = item.duration
774
+ return Self.classifyContentType(
775
+ durationIsNumeric: duration.isNumeric,
776
+ durationIsIndefinite: duration.isIndefinite,
777
+ itemReady: item.status == .readyToPlay
778
+ )
779
+ }
780
+
781
+ private func currentPlayheadMs() -> Int64? {
782
+ let seconds = player?.currentTime().seconds
783
+ guard let seconds, seconds.isFinite else { return nil }
784
+ return Int64(seconds * 1000.0)
785
+ }
786
+
787
+ private func currentDurationMs() -> Int64? {
788
+ guard let duration = player?.currentItem?.duration, duration.isNumeric else { return nil }
789
+ let seconds = duration.seconds
790
+ guard seconds.isFinite else { return nil }
791
+ return Int64(seconds * 1000.0)
792
+ }
793
+
794
+ /// The media bitrate resolved from the access log, or the last known-good
795
+ /// value when the newest event reports nothing.
796
+ ///
797
+ /// Never sourced from `observedBitrate`: that field is the empirical
798
+ /// network throughput, and publishing it here reported tens of Mbps for
799
+ /// low-bitrate content and swung several-fold run-to-run on byte-identical
800
+ /// media. Throughput is reported separately via `snapshotQos`.
801
+ private func currentBitrate() -> Int64? {
802
+ stateLock.lock()
803
+ defer { stateLock.unlock() }
804
+ return lastKnownBitrate
805
+ }
806
+
807
+ /// Reads the item's access log once and folds it into the cached facts.
808
+ /// Separated from the pure rules so the adapter wiring stays thin.
809
+ private func refreshPlaybackFactsFromPlayer(elapsedRealtimeMs: Int64) {
810
+ let events = player?.currentItem?.accessLog()?.events
811
+ let samples = events?.map(AVPlayerAccessLogSample.init)
812
+ refreshPlaybackFacts(
813
+ samples: samples,
814
+ frameRate: currentVideoFrameRate(),
815
+ sampleElapsedMs: elapsedRealtimeMs
816
+ )
817
+ }
818
+
819
+ /// Applies the resolution rules and the retention policy. Internal so the
820
+ /// regression tests can drive it without a live `AVPlayer`.
821
+ ///
822
+ /// Deduplicated on `sampleElapsedMs` because `VideoAdapter.metadataSnapshot`
823
+ /// calls `snapshotMetadata` and `snapshotQos` back-to-back with identical
824
+ /// values; without this the access log would be read twice per ping.
825
+ func refreshPlaybackFacts(
826
+ samples: [AVPlayerAccessLogSample]?,
827
+ frameRate: Double?,
828
+ sampleElapsedMs: Int64
829
+ ) {
830
+ stateLock.lock()
831
+ defer { stateLock.unlock() }
832
+ if let previous = lastFactsSampleElapsedMs, previous == sampleElapsedMs { return }
833
+ lastFactsSampleElapsedMs = sampleElapsedMs
834
+
835
+ if let frameRate, frameRate.isFinite, frameRate > 0 {
836
+ lastKnownFps = frameRate
837
+ }
838
+
839
+ guard let samples, let newest = samples.last else { return }
840
+
841
+ if let bitrate = AVPlayerAccessLogFacts.mediaBitrate(from: newest) {
842
+ lastKnownBitrate = bitrate
843
+ }
844
+ if let throughput = AVPlayerAccessLogFacts.observedThroughput(from: newest) {
845
+ lastKnownThroughput = throughput
846
+ }
847
+ if let dropped = AVPlayerAccessLogFacts.droppedFrames(in: samples) {
848
+ lastKnownDroppedFrames = max(dropped, lastKnownDroppedFrames ?? 0)
849
+ }
850
+ if let bytes = AVPlayerAccessLogFacts.totalBytes(in: samples) {
851
+ lastKnownTotalBytes = max(bytes, lastKnownTotalBytes ?? 0)
852
+ }
853
+ }
854
+
855
+ /// First track reporting a positive video frame rate.
856
+ ///
857
+ /// `currentVideoFrameRate` is synchronous and not deprecated, unlike the
858
+ /// `assetTrack`/`nominalFrameRate` pair that iOS 16 moved to async loading.
859
+ /// The media type is not inspected on purpose: only video tracks ever
860
+ /// report a positive video frame rate, so the `> 0` filter already selects
861
+ /// them without touching the async-loading `AVAssetTrack` surface.
862
+ private func currentVideoFrameRate() -> Double? {
863
+ guard let tracks = player?.currentItem?.tracks else { return nil }
864
+ for track in tracks {
865
+ let rate = Double(track.currentVideoFrameRate)
866
+ if rate.isFinite, rate > 0 { return rate }
867
+ }
868
+ return nil
869
+ }
870
+
871
+ private func currentRendition() -> String? {
872
+ guard let size = player?.currentItem?.presentationSize, size.width > 0, size.height > 0 else {
873
+ return nil
874
+ }
875
+ return "\(Int(size.width))x\(Int(size.height))"
876
+ }
877
+ }