@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.
- package/LICENSE +10 -0
- package/NPAWForgeReactNative.podspec +44 -0
- package/README.md +51 -0
- package/android/build.gradle +77 -0
- package/android/src/main/AndroidManifest.xml +14 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
- package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
- package/ios/ForgeModuleImpl.swift +1131 -0
- package/ios/NPAWForgeTurboModule.mm +399 -0
- package/package.json +33 -0
- package/src/ForgeAdReporter.ts +101 -0
- package/src/ForgeVideo.ts +169 -0
- package/src/NPAWForge.ts +177 -0
- package/src/capture.ts +192 -0
- package/src/events.ts +40 -0
- package/src/index.ts +37 -0
- package/src/specs/NativeNPAWForge.ts +168 -0
- 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
|
+
}
|