@npaw/forge-react-native-video 9.7.4 → 10.7.4

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.
@@ -1,363 +0,0 @@
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: Swift attach coordinator + module implementation for react-native-video on iOS. Mirrors the Android RNVideoAttachCoordinator semantics: auto single-player policy, X-NPAW-Forge token matching via asset identity, source-URI fallback, per-binding stopOnPlayerRemoved, double-create guard.
13
- */
14
-
15
- import AVFoundation
16
- import Foundation
17
- #if canImport(NPAWForgeCore)
18
- import NPAWForgeCore
19
- import NPAWForgeAVPlayerAdapter
20
- #elseif canImport(NPAWForgeApple)
21
- import NPAWForgeApple
22
- #endif
23
- #if canImport(NPAWForgeReactNative)
24
- import NPAWForgeReactNative
25
- #endif
26
-
27
- public typealias ForgeVideoResolveBlock = (Any?) -> Void
28
- public typealias ForgeVideoRejectBlock = (String, String) -> Void
29
-
30
- @objc(ForgeVideoModuleImpl)
31
- public final class ForgeVideoModuleImpl: NSObject {
32
-
33
- @objc public static let shared = ForgeVideoModuleImpl()
34
- static let tokenHeader = "x-npaw-forge"
35
-
36
- private struct AttachRequest {
37
- let instanceId: String
38
- let identifier: String
39
- let policy: String
40
- let headerToken: String?
41
- let sourceUri: String?
42
- let stopOnPlayerRemoved: Bool
43
- }
44
-
45
- private struct Binding {
46
- let request: AttachRequest
47
- let playerId: String
48
- let playerIdentity: ObjectIdentifier
49
- let adapter: AVPlayerAdapter
50
- }
51
-
52
- private let lock = NSLock()
53
- private var livePlayers: [(id: String, player: AVPlayer)] = []
54
- private var seenPlayerIdentities = Set<ObjectIdentifier>()
55
- private var assetFacts: [ObjectIdentifier: (token: String?, uri: String?)] = [:]
56
- private var pending: [AttachRequest] = []
57
- private var bindings: [String: Binding] = [:] // key: instanceId + " " + identifier
58
-
59
- private let resolveForge: (String) -> NPAWForge?
60
-
61
- private override init() {
62
- self.resolveForge = { ForgeModuleImpl.shared.resolveInstance($0) }
63
- super.init()
64
- }
65
-
66
- // MARK: - Spec surface
67
-
68
- @objc public func attach(
69
- _ instanceId: String,
70
- identifier: String,
71
- options: [String: Any]?,
72
- resolve: @escaping ForgeVideoResolveBlock,
73
- reject: @escaping ForgeVideoRejectBlock
74
- ) {
75
- ForgeRNVPlayerPlugin.registerOnce()
76
-
77
- guard let forge = resolveForge(instanceId) else {
78
- reject("E_FORGE_UNKNOWN_INSTANCE", "No NPAWForge instance '\(instanceId)'")
79
- return
80
- }
81
- ensureVideo(forge, identifier: identifier)
82
-
83
- let headerToken = nonEmpty(options?["headerToken"] as? String)
84
- let sourceUri = nonEmpty(options?["sourceUri"] as? String)
85
- var policy = nonEmpty(options?["policy"] as? String)
86
- if policy == nil {
87
- policy = (headerToken != nil || sourceUri != nil) ? "match" : "auto"
88
- }
89
- let request = AttachRequest(
90
- instanceId: instanceId,
91
- identifier: identifier,
92
- policy: policy ?? "auto",
93
- headerToken: headerToken,
94
- sourceUri: sourceUri,
95
- stopOnPlayerRemoved: options?["stopOnPlayerRemoved"] as? Bool ?? true
96
- )
97
-
98
- lock.lock()
99
- let key = bindingKey(instanceId, identifier)
100
- if bindings[key] != nil {
101
- lock.unlock()
102
- resolve(attachResult(code: "ignored_duplicate", accepted: false, attachment: "bound"))
103
- return
104
- }
105
- pending.removeAll { $0.instanceId == instanceId && $0.identifier == identifier }
106
-
107
- if request.policy == "auto" {
108
- let unbound = unboundPlayersLocked()
109
- if unbound.count > 1 {
110
- lock.unlock()
111
- resolve(
112
- attachResult(
113
- code: "unsupported_capability",
114
- accepted: false,
115
- attachment: "pending",
116
- reason: "ambiguous_auto_attach"
117
- )
118
- )
119
- return
120
- }
121
- if let candidate = unbound.first {
122
- bindLocked(request, playerId: candidate.id, player: candidate.player)
123
- lock.unlock()
124
- resolve(attachResult(code: "accepted", accepted: true, attachment: "bound"))
125
- return
126
- }
127
- } else if let candidate = findMatchLocked(request) {
128
- bindLocked(request, playerId: candidate.id, player: candidate.player)
129
- lock.unlock()
130
- resolve(attachResult(code: "accepted", accepted: true, attachment: "bound"))
131
- return
132
- }
133
-
134
- pending.append(request)
135
- lock.unlock()
136
- scheduleReevaluations()
137
- resolve(attachResult(code: "accepted", accepted: true, attachment: "pending"))
138
- }
139
-
140
- @objc public func detach(
141
- _ instanceId: String,
142
- identifier: String,
143
- resolve: @escaping ForgeVideoResolveBlock,
144
- reject: @escaping ForgeVideoRejectBlock
145
- ) {
146
- lock.lock()
147
- pending.removeAll { $0.instanceId == instanceId && $0.identifier == identifier }
148
- let removed = bindings.removeValue(forKey: bindingKey(instanceId, identifier))
149
- lock.unlock()
150
-
151
- guard let removed else {
152
- resolve(attachResult(code: "ignored_invalid_state", accepted: false, attachment: "pending"))
153
- return
154
- }
155
- unbindOnMain(removed, fireStop: true)
156
- resolve(attachResult(code: "accepted", accepted: true, attachment: "pending"))
157
- }
158
-
159
- @objc public func invalidate() {
160
- lock.lock()
161
- livePlayers.removeAll()
162
- seenPlayerIdentities.removeAll()
163
- assetFacts.removeAll()
164
- pending.removeAll()
165
- let current = Array(bindings.values)
166
- bindings.removeAll()
167
- lock.unlock()
168
-
169
- for binding in current {
170
- unbindOnMain(binding, fireStop: false)
171
- }
172
- }
173
-
174
- // MARK: - Plugin callbacks
175
-
176
- func playerCreated(id: String, player: AVPlayer) {
177
- let identity = ObjectIdentifier(player)
178
- lock.lock()
179
- if seenPlayerIdentities.contains(identity) {
180
- lock.unlock()
181
- NSLog("[NPAWForgeRNV] duplicate onInstanceCreated suppressed id=%@", id)
182
- return
183
- }
184
- seenPlayerIdentities.insert(identity)
185
- livePlayers.append((id: id, player: player))
186
- lock.unlock()
187
-
188
- reevaluate()
189
- scheduleReevaluations()
190
- }
191
-
192
- func playerRemoved(id: String, player: AVPlayer) {
193
- let identity = ObjectIdentifier(player)
194
- var affected: [Binding] = []
195
-
196
- lock.lock()
197
- livePlayers.removeAll { $0.id == id && ObjectIdentifier($0.player) == identity }
198
- seenPlayerIdentities.remove(identity)
199
- for (key, binding) in bindings where binding.playerId == id {
200
- bindings.removeValue(forKey: key)
201
- affected.append(binding)
202
- }
203
- lock.unlock()
204
-
205
- for binding in affected {
206
- unbindOnMain(binding, fireStop: binding.request.stopOnPlayerRemoved)
207
- }
208
- }
209
-
210
- func sourceSeen(asset: AVAsset, token: String?, uri: String?) {
211
- lock.lock()
212
- assetFacts[ObjectIdentifier(asset)] = (token: token, uri: uri)
213
- lock.unlock()
214
- reevaluate()
215
- }
216
-
217
- // MARK: - Coordinator internals
218
-
219
- private func bindingKey(_ instanceId: String, _ identifier: String) -> String {
220
- "\(instanceId) \(identifier)"
221
- }
222
-
223
- private func ensureVideo(_ forge: NPAWForge, identifier: String) {
224
- if forge.getVideoAdapter(identifier: identifier) == nil {
225
- _ = forge.videoBuilder().setIdentifier(identifier).build()
226
- }
227
- }
228
-
229
- private func unboundPlayersLocked() -> [(id: String, player: AVPlayer)] {
230
- let boundIds = Set(bindings.values.map { $0.playerId })
231
- return livePlayers.filter { !boundIds.contains($0.id) }
232
- }
233
-
234
- private func factsFor(_ player: AVPlayer) -> (token: String?, uri: String?) {
235
- if let asset = player.currentItem?.asset {
236
- if let stored = assetFacts[ObjectIdentifier(asset)] {
237
- return stored
238
- }
239
- if let urlAsset = asset as? AVURLAsset {
240
- return (token: nil, uri: urlAsset.url.absoluteString)
241
- }
242
- }
243
- return (token: nil, uri: nil)
244
- }
245
-
246
- private func findMatchLocked(_ request: AttachRequest) -> (id: String, player: AVPlayer)? {
247
- let candidates = unboundPlayersLocked()
248
- if let token = request.headerToken {
249
- for candidate in candidates where factsFor(candidate.player).token == token {
250
- return candidate
251
- }
252
- }
253
- if let uri = request.sourceUri {
254
- for candidate in candidates where factsFor(candidate.player).uri == uri {
255
- return candidate
256
- }
257
- }
258
- return nil
259
- }
260
-
261
- private func bindLocked(_ request: AttachRequest, playerId: String, player: AVPlayer) {
262
- let adapter = AVPlayerAdapter(player: player)
263
- bindings[bindingKey(request.instanceId, request.identifier)] = Binding(
264
- request: request,
265
- playerId: playerId,
266
- playerIdentity: ObjectIdentifier(player),
267
- adapter: adapter
268
- )
269
- let instanceId = request.instanceId
270
- let identifier = request.identifier
271
- DispatchQueue.main.async { [weak self] in
272
- guard let self, let forge = self.resolveForge(instanceId) else { return }
273
- self.ensureVideo(forge, identifier: identifier)
274
- guard let video = forge.getVideoAdapter(identifier: identifier) else { return }
275
- NSLog("[NPAWForgeRNV] binding AVPlayerAdapter playerId=%@ identifier=%@", playerId, identifier)
276
- _ = video.setPlayerAdapter(adapter)
277
- }
278
- }
279
-
280
- private func reevaluate() {
281
- lock.lock()
282
- var resolved: [AttachRequest] = []
283
- for request in pending {
284
- if request.policy == "auto" {
285
- let unbound = unboundPlayersLocked()
286
- if unbound.count == 1, let candidate = unbound.first {
287
- bindLocked(request, playerId: candidate.id, player: candidate.player)
288
- resolved.append(request)
289
- }
290
- } else if let candidate = findMatchLocked(request) {
291
- bindLocked(request, playerId: candidate.id, player: candidate.player)
292
- resolved.append(request)
293
- }
294
- }
295
- pending.removeAll { request in
296
- resolved.contains {
297
- $0.instanceId == request.instanceId && $0.identifier == request.identifier
298
- }
299
- }
300
- lock.unlock()
301
- }
302
-
303
- /// currentItem/asset facts can settle after creation; retry matching a few
304
- /// times so 'match' attaches converge without a dedicated KVO web.
305
- private func scheduleReevaluations() {
306
- for delay in [0.5, 1.5, 3.0] {
307
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
308
- self?.reevaluate()
309
- }
310
- }
311
- }
312
-
313
- /// Tears the binding down: detach the player adapter, then fire the
314
- /// terminal stop.
315
- ///
316
- /// The detach comes first deliberately. By the time this block runs,
317
- /// react-native-video has already released its player - `playerRemoved`
318
- /// arrives on the main thread and this hop defers a full runloop turn - so
319
- /// the adapter is observing a player that is being deallocated. Detaching
320
- /// first means the view is closed to it before the stop makes the view
321
- /// terminal, and nothing the dying player reports afterwards can be
322
- /// mistaken for a replay.
323
- ///
324
- /// The stop still carries the final position: it never read the player
325
- /// (`fireStop` builds its metadata from params), and the kernel backfills
326
- /// `playhead` from the last observed position when the firing params carry
327
- /// none - `backfill_stop_playhead`, added for
328
- /// `apple/2026-07-13_ios-stop-playhead-zero-instead-of-final-position`.
329
- private func unbindOnMain(_ binding: Binding, fireStop: Bool) {
330
- let instanceId = binding.request.instanceId
331
- let identifier = binding.request.identifier
332
- DispatchQueue.main.async { [weak self] in
333
- guard let self, let forge = self.resolveForge(instanceId) else { return }
334
- guard let video = forge.getVideoAdapter(identifier: identifier) else { return }
335
- _ = video.setPlayerAdapter(nil)
336
- if fireStop {
337
- _ = video.fireStop()
338
- }
339
- }
340
- }
341
-
342
- private func nonEmpty(_ value: String?) -> String? {
343
- guard let value, !value.isEmpty else { return nil }
344
- return value
345
- }
346
-
347
- private func attachResult(
348
- code: String,
349
- accepted: Bool,
350
- attachment: String,
351
- reason: String? = nil
352
- ) -> [String: Any] {
353
- var result: [String: Any] = [
354
- "code": code,
355
- "isAccepted": accepted,
356
- "attachment": attachment,
357
- ]
358
- if let reason {
359
- result["reason"] = reason
360
- }
361
- return result
362
- }
363
- }