@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.
- package/LICENSE +10 -0
- package/NPAWForgeReactNativeVideo.podspec +48 -0
- package/README.md +41 -0
- package/android/build.gradle +98 -0
- package/android/src/main/AndroidManifest.xml +14 -0
- package/android/src/main/java/com/npaw/forge/reactnative/video/NPAWForgeVideoModule.java +341 -0
- package/android/src/main/java/com/npaw/forge/reactnative/video/NPAWForgeVideoPackage.java +53 -0
- package/android/src/main/java/com/npaw/forge/reactnative/video/RNVideoAttachCoordinator.java +0 -0
- package/android/src/test/java/com/npaw/forge/reactnative/video/RNVideoAttachCoordinatorTest.java +218 -0
- package/ios/ForgeRNVPlayerPlugin.swift +80 -0
- package/ios/ForgeVideoModuleImpl.swift +347 -0
- package/ios/NPAWForgeVideoTurboModule.mm +89 -0
- package/package.json +34 -0
- package/src/index.ts +79 -0
- package/src/specs/NativeNPAWForgeVideo.ts +30 -0
package/android/src/test/java/com/npaw/forge/reactnative/video/RNVideoAttachCoordinatorTest.java
ADDED
|
@@ -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
|
+
}
|