@npaw/forge-react-native-video 9.7.3 → 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.
@@ -0,0 +1,474 @@
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 7 on iOS. Same ObjC surface and outcome contract as the v6 generation (auto single-player policy, X-NPAW-Forge token matching, source-URI fallback, per-binding stopOnPlayerRemoved), but the facts come straight from the public HybridVideoPlayer source (uri + config.headers) and one AVPlayer per hybrid means one bind per player - no asset-identity map, no Mirror reflection.
13
+ */
14
+
15
+ import AVFoundation
16
+ import Foundation
17
+ #if canImport(NitroModules)
18
+ import NitroModules
19
+ #endif
20
+ #if canImport(ReactNativeVideo)
21
+ import ReactNativeVideo
22
+ #endif
23
+ #if canImport(NPAWForgeCore)
24
+ import NPAWForgeCore
25
+ import NPAWForgeAVPlayerAdapter
26
+ #elseif canImport(NPAWForgeApple)
27
+ import NPAWForgeApple
28
+ #endif
29
+ #if canImport(NPAWForgeReactNative)
30
+ import NPAWForgeReactNative
31
+ #endif
32
+
33
+ public typealias ForgeVideoResolveBlock = (Any?) -> Void
34
+ public typealias ForgeVideoRejectBlock = (String, String) -> Void
35
+
36
+ @objc(ForgeVideoModuleImpl)
37
+ public final class ForgeVideoModuleImpl: NSObject {
38
+
39
+ @objc public static let shared = ForgeVideoModuleImpl()
40
+ static let tokenHeader = "x-npaw-forge"
41
+
42
+ private struct AttachRequest {
43
+ let instanceId: String
44
+ let identifier: String
45
+ let policy: String
46
+ let headerToken: String?
47
+ let sourceUri: String?
48
+ let stopOnPlayerRemoved: Bool
49
+ }
50
+
51
+ /// One rn-video player as this module sees it. Upstream hands out no id, so
52
+ /// the module mints one; the hybrid is held weakly (upstream owns its
53
+ /// lifetime), the AVPlayer strongly (the adapter observes it).
54
+ private final class LivePlayer {
55
+ let id: String
56
+ let identity: ObjectIdentifier
57
+ weak var hybrid: (any NativeVideoPlayer)?
58
+ let player: AVPlayer
59
+ // Facts snapshot, refreshed from the hybrid while it is alive.
60
+ var token: String?
61
+ var uri: String?
62
+
63
+ /// Whether this AVPlayer showed play intent (`timeControlStatus` other
64
+ /// than `.paused`) for its current item. That is exactly the condition
65
+ /// under which `AVPlayerAdapter` opens a started view - and therefore
66
+ /// fires the terminal stop itself when the item is later replaced with
67
+ /// nil. See `unbindOnMain`.
68
+ private var playIntent = false
69
+ private let playIntentLock = NSLock()
70
+ private var observations: [NSKeyValueObservation] = []
71
+
72
+ init(id: String, hybrid: any NativeVideoPlayer) {
73
+ self.id = id
74
+ self.identity = ObjectIdentifier(hybrid as AnyObject)
75
+ self.hybrid = hybrid
76
+ self.player = hybrid.player
77
+ observations.append(
78
+ player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in
79
+ guard let self, player.timeControlStatus != .paused else { return }
80
+ self.playIntentLock.lock()
81
+ self.playIntent = true
82
+ self.playIntentLock.unlock()
83
+ }
84
+ )
85
+ observations.append(
86
+ player.observe(\.currentItem, options: [.new]) { [weak self] player, _ in
87
+ // A NEW item resets the adapter's started flag (it stops the old
88
+ // view first); a transition to nil must keep the flag, because
89
+ // that is the teardown we consult it for.
90
+ guard let self, player.currentItem != nil else { return }
91
+ self.playIntentLock.lock()
92
+ self.playIntent = false
93
+ self.playIntentLock.unlock()
94
+ }
95
+ )
96
+ }
97
+
98
+ deinit {
99
+ observations.forEach { $0.invalidate() }
100
+ }
101
+
102
+ var sawPlayIntent: Bool {
103
+ playIntentLock.lock()
104
+ defer { playIntentLock.unlock() }
105
+ return playIntent
106
+ }
107
+ }
108
+
109
+ private struct Binding {
110
+ let request: AttachRequest
111
+ let playerId: String
112
+ let adapter: AVPlayerAdapter
113
+ }
114
+
115
+ private let lock = NSLock()
116
+ private var livePlayers: [LivePlayer] = []
117
+ private var sequence = 0
118
+ private var pending: [AttachRequest] = []
119
+ private var bindings: [String: Binding] = [:] // key: instanceId + " " + identifier
120
+
121
+ private let resolveForge: (String) -> NPAWForge?
122
+
123
+ private override init() {
124
+ self.resolveForge = { ForgeModuleImpl.shared.resolveInstance($0) }
125
+ super.init()
126
+ }
127
+
128
+ // MARK: - Spec surface
129
+
130
+ @objc public func attach(
131
+ _ instanceId: String,
132
+ identifier: String,
133
+ options: [String: Any]?,
134
+ resolve: @escaping ForgeVideoResolveBlock,
135
+ reject: @escaping ForgeVideoRejectBlock
136
+ ) {
137
+ ForgeRNVideoPlugin.registerOnce()
138
+
139
+ guard let forge = resolveForge(instanceId) else {
140
+ reject("E_FORGE_UNKNOWN_INSTANCE", "No NPAWForge instance '\(instanceId)'")
141
+ return
142
+ }
143
+ ensureVideo(forge, identifier: identifier)
144
+
145
+ let headerToken = nonEmpty(options?["headerToken"] as? String)
146
+ let sourceUri = nonEmpty(options?["sourceUri"] as? String)
147
+ var policy = nonEmpty(options?["policy"] as? String)
148
+ if policy == nil {
149
+ policy = (headerToken != nil || sourceUri != nil) ? "match" : "auto"
150
+ }
151
+ let request = AttachRequest(
152
+ instanceId: instanceId,
153
+ identifier: identifier,
154
+ policy: policy ?? "auto",
155
+ headerToken: headerToken,
156
+ sourceUri: sourceUri,
157
+ stopOnPlayerRemoved: options?["stopOnPlayerRemoved"] as? Bool ?? true
158
+ )
159
+
160
+ lock.lock()
161
+ let key = bindingKey(instanceId, identifier)
162
+ if bindings[key] != nil {
163
+ lock.unlock()
164
+ resolve(attachResult(code: "ignored_duplicate", accepted: false, attachment: "bound"))
165
+ return
166
+ }
167
+ pending.removeAll { $0.instanceId == instanceId && $0.identifier == identifier }
168
+ refreshFactsLocked()
169
+
170
+ if request.policy == "auto" {
171
+ let unbound = unboundPlayersLocked()
172
+ if unbound.count > 1 {
173
+ lock.unlock()
174
+ resolve(
175
+ attachResult(
176
+ code: "unsupported_capability",
177
+ accepted: false,
178
+ attachment: "pending",
179
+ reason: "ambiguous_auto_attach"
180
+ )
181
+ )
182
+ return
183
+ }
184
+ if let candidate = unbound.first {
185
+ bindLocked(request, to: candidate)
186
+ lock.unlock()
187
+ resolve(attachResult(code: "accepted", accepted: true, attachment: "bound"))
188
+ return
189
+ }
190
+ } else if let candidate = findMatchLocked(request) {
191
+ bindLocked(request, to: candidate)
192
+ lock.unlock()
193
+ resolve(attachResult(code: "accepted", accepted: true, attachment: "bound"))
194
+ return
195
+ }
196
+
197
+ pending.append(request)
198
+ lock.unlock()
199
+ scheduleReevaluations()
200
+ resolve(attachResult(code: "accepted", accepted: true, attachment: "pending"))
201
+ }
202
+
203
+ @objc public func detach(
204
+ _ instanceId: String,
205
+ identifier: String,
206
+ resolve: @escaping ForgeVideoResolveBlock,
207
+ reject: @escaping ForgeVideoRejectBlock
208
+ ) {
209
+ lock.lock()
210
+ pending.removeAll { $0.instanceId == instanceId && $0.identifier == identifier }
211
+ let removed = bindings.removeValue(forKey: bindingKey(instanceId, identifier))
212
+ lock.unlock()
213
+
214
+ guard let removed else {
215
+ resolve(attachResult(code: "ignored_invalid_state", accepted: false, attachment: "pending"))
216
+ return
217
+ }
218
+ unbindOnMain(removed, fireStop: true, live: livePlayer(id: removed.playerId))
219
+ resolve(attachResult(code: "accepted", accepted: true, attachment: "pending"))
220
+ }
221
+
222
+ @objc public func invalidate() {
223
+ lock.lock()
224
+ livePlayers.removeAll()
225
+ pending.removeAll()
226
+ let current = Array(bindings.values)
227
+ bindings.removeAll()
228
+ lock.unlock()
229
+
230
+ for binding in current {
231
+ unbindOnMain(binding, fireStop: false, live: nil)
232
+ }
233
+ ForgeRNVideoPlugin.unregister()
234
+ }
235
+
236
+ // MARK: - Plugin callbacks (main thread)
237
+
238
+ func playerCreated(_ hybrid: any NativeVideoPlayer) {
239
+ let identity = ObjectIdentifier(hybrid as AnyObject)
240
+ lock.lock()
241
+ if livePlayers.contains(where: { $0.identity == identity }) {
242
+ // v7 has a single create call site; the guard stays because it is free
243
+ // and a duplicate would otherwise double-bind.
244
+ lock.unlock()
245
+ NSLog("[NPAWForgeRNV] duplicate onPlayerCreated suppressed")
246
+ return
247
+ }
248
+ sequence += 1
249
+ let live = LivePlayer(id: "rnv7-\(sequence)", hybrid: hybrid)
250
+ refreshFacts(live)
251
+ livePlayers.append(live)
252
+ NSLog("[NPAWForgeRNV] player created id=%@ uri=%@", live.id, live.uri ?? "-")
253
+ lock.unlock()
254
+
255
+ reevaluate()
256
+ scheduleReevaluations()
257
+ }
258
+
259
+ func playerDestroyed(_ hybrid: (any NativeVideoPlayer)?) {
260
+ var affected: [(Binding, LivePlayer)] = []
261
+ var removed: [LivePlayer] = []
262
+
263
+ lock.lock()
264
+ let identity = hybrid.map { ObjectIdentifier($0 as AnyObject) }
265
+ livePlayers.removeAll { live in
266
+ // The notified hybrid, plus anything whose weak reference already
267
+ // cleared (upstream can notify from the hybrid's deinit).
268
+ let gone = live.identity == identity || live.hybrid == nil
269
+ if gone { removed.append(live) }
270
+ return gone
271
+ }
272
+ for (key, binding) in bindings {
273
+ if let live = removed.first(where: { $0.id == binding.playerId }) {
274
+ bindings.removeValue(forKey: key)
275
+ affected.append((binding, live))
276
+ }
277
+ }
278
+ lock.unlock()
279
+
280
+ // Upstream notifies on the main thread AFTER it replaced the item with
281
+ // nil, and the AVPlayer keeps delivering transitional KVO (rate /
282
+ // timeControlStatus) for a moment. Detach synchronously here, on this
283
+ // very runloop turn, so those late deliveries meet a detached adapter
284
+ // instead of re-opening a view the adapter has just stopped.
285
+ for (binding, live) in affected {
286
+ unbindOnMain(binding, fireStop: binding.request.stopOnPlayerRemoved, live: live)
287
+ }
288
+ }
289
+
290
+ // MARK: - Coordinator internals
291
+
292
+ private func bindingKey(_ instanceId: String, _ identifier: String) -> String {
293
+ "\(instanceId) \(identifier)"
294
+ }
295
+
296
+ private func livePlayer(id: String) -> LivePlayer? {
297
+ lock.lock()
298
+ defer { lock.unlock() }
299
+ return livePlayers.first { $0.id == id }
300
+ }
301
+
302
+ private func ensureVideo(_ forge: NPAWForge, identifier: String) {
303
+ if forge.getVideoAdapter(identifier: identifier) == nil {
304
+ _ = forge.videoBuilder().setIdentifier(identifier).build()
305
+ }
306
+ }
307
+
308
+ private func unboundPlayersLocked() -> [LivePlayer] {
309
+ let boundIds = Set(bindings.values.map { $0.playerId })
310
+ return livePlayers.filter { !boundIds.contains($0.id) }
311
+ }
312
+
313
+ /// Facts live on the hybrid's public source (`uri`, `config.headers`); the
314
+ /// source can be replaced after creation, so they are re-read on every
315
+ /// evaluation while the hybrid is alive and the last snapshot is kept after.
316
+ private func refreshFacts(_ live: LivePlayer) {
317
+ guard let hybrid = live.hybrid else { return }
318
+ let source = hybrid.source
319
+ live.uri = source.uri
320
+ var token: String?
321
+ if let headers = source.config.headers {
322
+ for (key, value) in headers where key.lowercased() == ForgeVideoModuleImpl.tokenHeader {
323
+ token = value.isEmpty ? nil : value
324
+ if token != nil { break }
325
+ }
326
+ }
327
+ live.token = token
328
+ }
329
+
330
+ private func refreshFactsLocked() {
331
+ for live in livePlayers {
332
+ refreshFacts(live)
333
+ }
334
+ }
335
+
336
+ private func findMatchLocked(_ request: AttachRequest) -> LivePlayer? {
337
+ let candidates = unboundPlayersLocked()
338
+ if let token = request.headerToken {
339
+ for candidate in candidates where candidate.token == token {
340
+ return candidate
341
+ }
342
+ }
343
+ if let uri = request.sourceUri {
344
+ for candidate in candidates where candidate.uri == uri {
345
+ return candidate
346
+ }
347
+ }
348
+ return nil
349
+ }
350
+
351
+ private func bindLocked(_ request: AttachRequest, to live: LivePlayer) {
352
+ let adapter = AVPlayerAdapter(player: live.player)
353
+ bindings[bindingKey(request.instanceId, request.identifier)] = Binding(
354
+ request: request,
355
+ playerId: live.id,
356
+ adapter: adapter
357
+ )
358
+ let instanceId = request.instanceId
359
+ let identifier = request.identifier
360
+ let playerId = live.id
361
+ DispatchQueue.main.async { [weak self] in
362
+ guard let self, let forge = self.resolveForge(instanceId) else { return }
363
+ self.ensureVideo(forge, identifier: identifier)
364
+ guard let video = forge.getVideoAdapter(identifier: identifier) else { return }
365
+ NSLog("[NPAWForgeRNV] binding AVPlayerAdapter playerId=%@ identifier=%@", playerId, identifier)
366
+ _ = video.setPlayerAdapter(adapter)
367
+ }
368
+ }
369
+
370
+ private func reevaluate() {
371
+ lock.lock()
372
+ refreshFactsLocked()
373
+ var resolved: [AttachRequest] = []
374
+ for request in pending {
375
+ if request.policy == "auto" {
376
+ let unbound = unboundPlayersLocked()
377
+ if unbound.count == 1, let candidate = unbound.first {
378
+ bindLocked(request, to: candidate)
379
+ resolved.append(request)
380
+ }
381
+ } else if let candidate = findMatchLocked(request) {
382
+ bindLocked(request, to: candidate)
383
+ resolved.append(request)
384
+ }
385
+ }
386
+ pending.removeAll { request in
387
+ resolved.contains {
388
+ $0.instanceId == request.instanceId && $0.identifier == request.identifier
389
+ }
390
+ }
391
+ lock.unlock()
392
+ }
393
+
394
+ /// Sources can be replaced after creation (replaceSourceAsync); retry matching
395
+ /// a few times so 'match' attaches converge without observing upstream state.
396
+ private func scheduleReevaluations() {
397
+ for delay in [0.5, 1.5, 3.0] {
398
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
399
+ self?.reevaluate()
400
+ }
401
+ }
402
+ }
403
+
404
+ /// Tears the binding down: detach the player adapter, then fire the terminal
405
+ /// stop unless the adapter already did. Detach first on purpose: by the time
406
+ /// this block runs upstream may already have released its player, so the
407
+ /// view is closed to the adapter before the stop makes it terminal (same
408
+ /// rationale as the v6 generation). The stop keeps the final position
409
+ /// through the kernel's playhead backfill.
410
+ ///
411
+ /// rn-video 7 tears a player down by `replaceCurrentItem(with: nil)` BEFORE
412
+ /// it notifies plugins (`HybridVideoPlayer.releaseOnMainThread`), while
413
+ /// rn-video 6 notified first. So on the removal path the still-attached
414
+ /// `AVPlayerAdapter` has already seen the item transition and, if the view
415
+ /// had started, has already fired its terminal stop. A second stop would
416
+ /// make the kernel open a replacement view (`init -> stop`, the very phantom
417
+ /// `native/2026-08-13_rn-video-teardown-reinits-view` fixed for v6), so the
418
+ /// explicit stop is skipped exactly when the item is already gone AND the
419
+ /// player had shown play intent (= the adapter's own start condition). A
420
+ /// player torn down before it ever played keeps the explicit stop, which
421
+ /// closes its init-only view as a genuine exit-before-start.
422
+ private func unbindOnMain(_ binding: Binding, fireStop: Bool, live: LivePlayer?) {
423
+ let instanceId = binding.request.instanceId
424
+ let identifier = binding.request.identifier
425
+ let playerId = binding.playerId
426
+ let work: () -> Void = { [weak self] in
427
+ guard let self, let forge = self.resolveForge(instanceId) else { return }
428
+ guard let video = forge.getVideoAdapter(identifier: identifier) else { return }
429
+ _ = video.setPlayerAdapter(nil)
430
+ guard fireStop else { return }
431
+ if let live, live.player.currentItem == nil, live.sawPlayIntent {
432
+ NSLog(
433
+ "[NPAWForgeRNV] terminal stop already reported by the adapter for playerId=%@ identifier=%@ (item removed upstream before the plugin was notified)",
434
+ playerId, identifier)
435
+ return
436
+ }
437
+ _ = video.fireStop()
438
+ }
439
+ // Run inline when already on the main thread (the plugin's removal
440
+ // callback): deferring by a runloop turn is exactly the window in which
441
+ // a dying AVPlayer's late KVO could still reach the adapter.
442
+ if Thread.isMainThread {
443
+ work()
444
+ } else {
445
+ DispatchQueue.main.async(execute: work)
446
+ }
447
+ }
448
+
449
+ private func nonEmpty(_ value: String?) -> String? {
450
+ guard let value, !value.isEmpty else { return nil }
451
+ return value
452
+ }
453
+
454
+ private func attachResult(
455
+ code: String,
456
+ accepted: Bool,
457
+ attachment: String,
458
+ reason: String? = nil
459
+ ) -> [String: Any] {
460
+ var result: [String: Any] = [
461
+ "code": code,
462
+ "isAccepted": accepted,
463
+ "attachment": attachment,
464
+ ]
465
+ if let reason {
466
+ result["reason"] = reason
467
+ }
468
+ // One line per attach/detach outcome in the device log, next to the
469
+ // player-created/binding lines, so a smoke run is legible without the
470
+ // JS console (RN >= 0.79 no longer echoes console.log to Metro).
471
+ NSLog("[NPAWForgeRNV] outcome code=%@ attachment=%@ reason=%@", code, attachment, reason ?? "-")
472
+ return result
473
+ }
474
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@npaw/forge-react-native-video",
3
- "version": "9.7.3",
4
- "description": "react-native-video adapter glue for NPAW Forge: attaches the native Media3/AVPlayer Forge adapters to react-native-video's internal players via its native plugin API.",
3
+ "version": "10.7.4",
4
+ "description": "react-native-video adapter glue for NPAW Forge: attaches the native Media3/AVPlayer Forge adapters to react-native-video's internal players via its native plugin API (react-native-video 6 and 7 generations, one major each).",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Victor Soto <victor.soto@nicepeopleatwork.com>",
7
7
  "main": "src/index.ts",
@@ -11,6 +11,7 @@
11
11
  "NPAWForgeReactNativeVideo.podspec",
12
12
  "README.md",
13
13
  "android",
14
+ "generations.json",
14
15
  "ios",
15
16
  "src"
16
17
  ],
@@ -19,10 +20,11 @@
19
20
  "test": "jest"
20
21
  },
21
22
  "peerDependencies": {
22
- "@npaw/forge-react-native": "^9.7.3",
23
+ "@npaw/forge-react-native": "^9.7.4",
23
24
  "react": "*",
24
25
  "react-native": "*",
25
- "react-native-video": ">=6.11.0 <7"
26
+ "react-native-video": ">=7.0.0-beta.11 <8",
27
+ "react-native-nitro-modules": ">=0.35.0"
26
28
  },
27
29
  "codegenConfig": {
28
30
  "name": "NPAWForgeVideoSpec",