@npaw/forge-react-native-video 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.
@@ -0,0 +1,218 @@
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: JVM unit tests for the attach correlation policy engine (fake players; no ExoPlayer or Android runtime).
13
+ */
14
+
15
+ package com.npaw.forge.reactnative.video;
16
+
17
+ import static org.junit.Assert.assertEquals;
18
+ import static org.junit.Assert.assertFalse;
19
+ import static org.junit.Assert.assertTrue;
20
+
21
+ import androidx.annotation.Nullable;
22
+ import java.util.ArrayList;
23
+ import java.util.HashMap;
24
+ import java.util.List;
25
+ import java.util.Map;
26
+ import org.junit.Before;
27
+ import org.junit.Test;
28
+
29
+ public class RNVideoAttachCoordinatorTest {
30
+
31
+ private static final class FakeBinder implements RNVideoAttachCoordinator.Binder<Object> {
32
+ final List<String> binds = new ArrayList<>();
33
+ final List<String> unbinds = new ArrayList<>();
34
+
35
+ @Override
36
+ public void bind(String instanceId, String identifier, String playerId, Object player) {
37
+ binds.add(identifier + "->" + playerId);
38
+ }
39
+
40
+ @Override
41
+ public void unbind(String instanceId, String identifier, String playerId, boolean fireStop) {
42
+ unbinds.add(identifier + "->" + playerId + (fireStop ? " stop" : " silent"));
43
+ }
44
+ }
45
+
46
+ private final Map<String, String> tokens = new HashMap<>();
47
+ private final Map<String, String> uris = new HashMap<>();
48
+ private FakeBinder binder;
49
+ private RNVideoAttachCoordinator<Object> coordinator;
50
+
51
+ @Before
52
+ public void setUp() {
53
+ tokens.clear();
54
+ uris.clear();
55
+ binder = new FakeBinder();
56
+ coordinator =
57
+ new RNVideoAttachCoordinator<>(
58
+ new RNVideoAttachCoordinator.PlayerIntrospection() {
59
+ @Nullable
60
+ @Override
61
+ public String currentToken(String playerId) {
62
+ return tokens.get(playerId);
63
+ }
64
+
65
+ @Nullable
66
+ @Override
67
+ public String currentUri(String playerId) {
68
+ return uris.get(playerId);
69
+ }
70
+ },
71
+ binder);
72
+ }
73
+
74
+ private static RNVideoAttachCoordinator.AttachRequest auto(String identifier) {
75
+ return new RNVideoAttachCoordinator.AttachRequest(
76
+ "forge-1", identifier, "auto", null, null, true);
77
+ }
78
+
79
+ private static RNVideoAttachCoordinator.AttachRequest token(String identifier, String token) {
80
+ return new RNVideoAttachCoordinator.AttachRequest(
81
+ "forge-1", identifier, "match", token, null, true);
82
+ }
83
+
84
+ private static RNVideoAttachCoordinator.AttachRequest uri(String identifier, String uri) {
85
+ return new RNVideoAttachCoordinator.AttachRequest(
86
+ "forge-1", identifier, "match", null, uri, true);
87
+ }
88
+
89
+ @Test
90
+ public void attachBeforeCreateBindsWhenPlayerArrives() {
91
+ RNVideoAttachCoordinator.AttachOutcome outcome = coordinator.attach(auto("default"));
92
+ assertEquals("pending", outcome.attachment);
93
+ assertTrue(outcome.accepted);
94
+
95
+ coordinator.playerCreated("p1", new Object());
96
+ assertEquals(List.of("default->p1"), binder.binds);
97
+ }
98
+
99
+ @Test
100
+ public void createBeforeAttachBindsImmediately() {
101
+ coordinator.playerCreated("p1", new Object());
102
+ RNVideoAttachCoordinator.AttachOutcome outcome = coordinator.attach(auto("default"));
103
+ assertEquals("bound", outcome.attachment);
104
+ assertEquals(List.of("default->p1"), binder.binds);
105
+ }
106
+
107
+ @Test
108
+ public void autoRefusesAmbiguity() {
109
+ coordinator.playerCreated("p1", new Object());
110
+ coordinator.playerCreated("p2", new Object());
111
+ RNVideoAttachCoordinator.AttachOutcome outcome = coordinator.attach(auto("default"));
112
+ assertEquals("unsupported_capability", outcome.code);
113
+ assertEquals("ambiguous_auto_attach", outcome.reason);
114
+ assertTrue(binder.binds.isEmpty());
115
+ }
116
+
117
+ @Test
118
+ public void autoPendingDoesNotBindWhileAmbiguous() {
119
+ coordinator.attach(auto("default"));
120
+ coordinator.playerCreated("p1", new Object());
121
+ coordinator.playerCreated("p2", new Object());
122
+ // p1 bound the pending attach when it was the single player; p2 arriving
123
+ // afterwards must not steal or duplicate.
124
+ assertEquals(List.of("default->p1"), binder.binds);
125
+ }
126
+
127
+ @Test
128
+ public void headerTokenMatchesTheRightPlayer() {
129
+ coordinator.playerCreated("p1", new Object());
130
+ coordinator.playerCreated("p2", new Object());
131
+ tokens.put("p2", "main");
132
+
133
+ RNVideoAttachCoordinator.AttachOutcome outcome = coordinator.attach(token("default", "main"));
134
+ assertEquals("bound", outcome.attachment);
135
+ assertEquals(List.of("default->p2"), binder.binds);
136
+ }
137
+
138
+ @Test
139
+ public void tokenSettlingLateBindsOnSourceSettled() {
140
+ coordinator.playerCreated("p1", new Object());
141
+ coordinator.playerCreated("p2", new Object());
142
+ RNVideoAttachCoordinator.AttachOutcome outcome = coordinator.attach(token("default", "main"));
143
+ assertEquals("pending", outcome.attachment);
144
+
145
+ tokens.put("p1", "main");
146
+ coordinator.playerSourceSettled();
147
+ assertEquals(List.of("default->p1"), binder.binds);
148
+ }
149
+
150
+ @Test
151
+ public void sourceUriFallbackMatches() {
152
+ coordinator.playerCreated("p1", new Object());
153
+ coordinator.playerCreated("p2", new Object());
154
+ uris.put("p2", "https://cdn/x.m3u8");
155
+
156
+ coordinator.attach(uri("default", "https://cdn/x.m3u8"));
157
+ assertEquals(List.of("default->p2"), binder.binds);
158
+ }
159
+
160
+ @Test
161
+ public void twoIdentifiersTwoTokensBindDistinctPlayers() {
162
+ coordinator.playerCreated("p1", new Object());
163
+ coordinator.playerCreated("p2", new Object());
164
+ tokens.put("p1", "a");
165
+ tokens.put("p2", "b");
166
+
167
+ coordinator.attach(token("main", "a"));
168
+ coordinator.attach(token("pip", "b"));
169
+ assertEquals(List.of("main->p1", "pip->b".replace("b", "p2")), binder.binds);
170
+ }
171
+
172
+ @Test
173
+ public void duplicateAttachIsIgnored() {
174
+ coordinator.playerCreated("p1", new Object());
175
+ coordinator.attach(auto("default"));
176
+ RNVideoAttachCoordinator.AttachOutcome outcome = coordinator.attach(auto("default"));
177
+ assertEquals("ignored_duplicate", outcome.code);
178
+ assertEquals(1, binder.binds.size());
179
+ }
180
+
181
+ @Test
182
+ public void detachUnbindsWithStopAndAllowsRebind() {
183
+ coordinator.playerCreated("p1", new Object());
184
+ coordinator.attach(auto("default"));
185
+
186
+ assertTrue(coordinator.detach("forge-1", "default", true));
187
+ assertEquals(List.of("default->p1 stop"), binder.unbinds);
188
+
189
+ coordinator.attach(auto("default"));
190
+ assertEquals(2, binder.binds.size());
191
+ }
192
+
193
+ @Test
194
+ public void detachWithoutBindingReportsFalse() {
195
+ assertFalse(coordinator.detach("forge-1", "default", true));
196
+ }
197
+
198
+ @Test
199
+ public void playerRemovedHonorsStopOnPlayerRemoved() {
200
+ coordinator.playerCreated("p1", new Object());
201
+ coordinator.attach(
202
+ new RNVideoAttachCoordinator.AttachRequest("forge-1", "default", "auto", null, null, false));
203
+ coordinator.playerRemoved("p1");
204
+ assertEquals(List.of("default->p1 silent"), binder.unbinds);
205
+ }
206
+
207
+ @Test
208
+ public void reattachAfterRemovalGoesPendingThenBindsNextPlayer() {
209
+ coordinator.playerCreated("p1", new Object());
210
+ coordinator.attach(auto("default"));
211
+ coordinator.playerRemoved("p1");
212
+
213
+ RNVideoAttachCoordinator.AttachOutcome outcome = coordinator.attach(auto("default"));
214
+ assertEquals("pending", outcome.attachment);
215
+ coordinator.playerCreated("p2", new Object());
216
+ assertEquals(List.of("default->p1", "default->p2"), binder.binds);
217
+ }
218
+ }
@@ -0,0 +1,80 @@
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: Mandatory RNVAVPlayerPlugin subclass (split object from the TurboModule); forwards react-native-video player lifecycle and source/asset sightings to the Forge attach coordinator.
13
+ */
14
+
15
+ import AVFoundation
16
+ import Foundation
17
+ #if canImport(react_native_video)
18
+ import react_native_video
19
+ #endif
20
+
21
+ /// react-native-video stores plugins in an unordered Set and RCTVideo has two
22
+ /// onInstanceCreated call sites, so this object registers exactly once and the
23
+ /// coordinator dedupes repeated deliveries of the same player.
24
+ final class ForgeRNVPlayerPlugin: RNVAVPlayerPlugin {
25
+
26
+ static let shared = ForgeRNVPlayerPlugin()
27
+ private static var registered = false
28
+ private static let registrationLock = NSLock()
29
+
30
+ static func registerOnce() {
31
+ registrationLock.lock()
32
+ defer { registrationLock.unlock() }
33
+ guard !registered else { return }
34
+ registered = true
35
+ ReactNativeVideoManager.shared.registerPlugin(plugin: shared)
36
+ }
37
+
38
+ override func onInstanceCreated(id: String, player: AVPlayer) {
39
+ NSLog("[NPAWForgeRNV] player created id=%@", id)
40
+ ForgeVideoModuleImpl.shared.playerCreated(id: id, player: player)
41
+ }
42
+
43
+ override func onInstanceRemoved(id: String, player: AVPlayer) {
44
+ ForgeVideoModuleImpl.shared.playerRemoved(id: id, player: player)
45
+ }
46
+
47
+ /// Called for every AVPlayerItem creation; we never override the asset,
48
+ /// but we record (asset identity -> token/uri) so the coordinator can
49
+ /// correlate players with sources deterministically.
50
+ ///
51
+ /// VideoSource's fields are `internal` to react-native-video, so the
52
+ /// token/uri are read via Mirror reflection (read-only), with the
53
+ /// AVURLAsset URL as the fallback. Follow-up filed to propose public
54
+ /// accessors upstream.
55
+ override func overridePlayerAsset(source: VideoSource, asset: AVAsset) async -> OverridePlayerAssetResult? {
56
+ var token: String?
57
+ var uri: String?
58
+ let mirror = Mirror(reflecting: source)
59
+ for child in mirror.children {
60
+ switch child.label {
61
+ case "uri":
62
+ uri = child.value as? String ?? (child.value as? String?) ?? nil
63
+ case "requestHeaders":
64
+ let headers = (child.value as? [String: Any]) ?? (child.value as? [String: Any]?) ?? nil
65
+ if let headers {
66
+ for (key, value) in headers where key.lowercased() == ForgeVideoModuleImpl.tokenHeader {
67
+ token = value as? String
68
+ }
69
+ }
70
+ default:
71
+ break
72
+ }
73
+ }
74
+ if uri == nil, let urlAsset = asset as? AVURLAsset {
75
+ uri = urlAsset.url.absoluteString
76
+ }
77
+ ForgeVideoModuleImpl.shared.sourceSeen(asset: asset, token: token, uri: uri)
78
+ return nil
79
+ }
80
+ }
@@ -0,0 +1,347 @@
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
+ private func unbindOnMain(_ binding: Binding, fireStop: Bool) {
314
+ let instanceId = binding.request.instanceId
315
+ let identifier = binding.request.identifier
316
+ DispatchQueue.main.async { [weak self] in
317
+ guard let self, let forge = self.resolveForge(instanceId) else { return }
318
+ guard let video = forge.getVideoAdapter(identifier: identifier) else { return }
319
+ if fireStop {
320
+ _ = video.fireStop()
321
+ }
322
+ _ = video.setPlayerAdapter(nil)
323
+ }
324
+ }
325
+
326
+ private func nonEmpty(_ value: String?) -> String? {
327
+ guard let value, !value.isEmpty else { return nil }
328
+ return value
329
+ }
330
+
331
+ private func attachResult(
332
+ code: String,
333
+ accepted: Bool,
334
+ attachment: String,
335
+ reason: String? = nil
336
+ ) -> [String: Any] {
337
+ var result: [String: Any] = [
338
+ "code": code,
339
+ "isAccepted": accepted,
340
+ "attachment": attachment,
341
+ ]
342
+ if let reason {
343
+ result["reason"] = reason
344
+ }
345
+ return result
346
+ }
347
+ }