react-native-meshstream 0.0.0-stage → 1.0.0

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,367 @@
1
+ package net.meshstream.reactnative
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.Promise
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
7
+ import com.facebook.react.bridge.ReactMethod
8
+ import com.facebook.react.bridge.ReadableMap
9
+ import com.facebook.react.bridge.ReadableMapKeySetIterator
10
+ import com.facebook.react.bridge.ReadableType
11
+ import com.facebook.react.bridge.WritableMap
12
+ import com.facebook.react.modules.core.DeviceEventManagerModule
13
+ import io.meshstream.sdk.P2pClient
14
+ import io.meshstream.sdk.P2pConfig
15
+ import io.meshstream.sdk.P2pDiagnosticsSnapshot
16
+ import io.meshstream.sdk.P2pEventListener
17
+ import io.meshstream.sdk.P2pSdk
18
+ import io.meshstream.sdk.P2pSdkStatus
19
+ import io.meshstream.sdk.P2pStats
20
+ import io.meshstream.sdk.P2pUnavailableReason
21
+ import io.meshstream.sdk.PlaybackOptions
22
+ import io.meshstream.sdk.PlaybackSession
23
+ import java.util.UUID
24
+ import java.util.concurrent.ConcurrentHashMap
25
+
26
+ /**
27
+ * Classic React Native module backed by the Android MeshStream SDK. Native state is
28
+ * entirely keyed by the JS-visible client/session IDs so several players can coexist.
29
+ */
30
+ class MeshStreamP2PModule(
31
+ reactContext: ReactApplicationContext,
32
+ ) : ReactContextBaseJavaModule(reactContext) {
33
+ private data class BridgeClient(
34
+ val client: P2pClient,
35
+ val sessions: ConcurrentHashMap<String, PlaybackSession> = ConcurrentHashMap(),
36
+ val pendingEvents: ConcurrentHashMap<Long, MutableList<PendingEvent>> = ConcurrentHashMap(),
37
+ )
38
+
39
+ private data class PendingEvent(
40
+ val type: String,
41
+ val extras: WritableMap.() -> Unit,
42
+ )
43
+
44
+ private val clients = ConcurrentHashMap<String, BridgeClient>()
45
+
46
+ override fun getName(): String = MODULE_NAME
47
+
48
+ @ReactMethod
49
+ fun createClient(config: ReadableMap, promise: Promise) {
50
+ try {
51
+ val key = config.requiredString("key")
52
+ val client = P2pSdk.create(
53
+ reactApplicationContext.applicationContext,
54
+ P2pConfig(
55
+ key = key,
56
+ p2pEnabled = config.optionalBoolean("p2pEnabled") ?: true,
57
+ ),
58
+ )
59
+ val clientId = UUID.randomUUID().toString()
60
+ val bridgeClient = BridgeClient(client)
61
+ client.addEventListener(createEventListener(clientId, bridgeClient))
62
+ clients[clientId] = bridgeClient
63
+ promise.resolve(Arguments.createMap().apply {
64
+ putString("clientId", clientId)
65
+ putMap("status", client.status.toWritableMap())
66
+ })
67
+ } catch (error: Throwable) {
68
+ promise.reject("MESHSTREAM_CREATE_CLIENT_FAILED", error.message, error)
69
+ }
70
+ }
71
+
72
+ @ReactMethod
73
+ fun createPlaybackSession(clientId: String, options: ReadableMap, promise: Promise) {
74
+ try {
75
+ val bridgeClient = clients.requireClient(clientId)
76
+ val url = options.requiredString("url")
77
+ val session = bridgeClient.client.createPlaybackSession(
78
+ url,
79
+ PlaybackOptions(
80
+ contentId = options.optionalString("contentId"),
81
+ mimeType = options.optionalString("mimeType"),
82
+ originHeaders = options.stringMap("originHeaders"),
83
+ ),
84
+ )
85
+ val sessionId = UUID.randomUUID().toString()
86
+ bridgeClient.sessions[sessionId] = session
87
+ flushPendingEvents(clientId, sessionId, session, bridgeClient)
88
+ promise.resolve(session.toWritableMap(clientId, sessionId))
89
+ } catch (error: Throwable) {
90
+ promise.reject("MESHSTREAM_CREATE_SESSION_FAILED", error.message, error)
91
+ }
92
+ }
93
+
94
+ @ReactMethod
95
+ fun preload(clientId: String, sessionId: String, promise: Promise) = withSession(clientId, sessionId, promise) {
96
+ it.preload()
97
+ }
98
+
99
+ @ReactMethod
100
+ fun setP2pEnabled(clientId: String, sessionId: String, enabled: Boolean, promise: Promise) =
101
+ withSession(clientId, sessionId, promise) {
102
+ it.setP2pEnabled(enabled)
103
+ null
104
+ }
105
+
106
+ @ReactMethod
107
+ fun updateBufferedDurationMs(clientId: String, sessionId: String, durationMs: Double, promise: Promise) =
108
+ withSession(clientId, sessionId, promise) {
109
+ it.updateBufferedDurationMs(durationMs.toLong().coerceAtLeast(0))
110
+ null
111
+ }
112
+
113
+ @ReactMethod
114
+ fun updateCurrentPositionMs(clientId: String, sessionId: String, positionMs: Double, promise: Promise) =
115
+ withSession(clientId, sessionId, promise) {
116
+ it.updateCurrentPositionMs(positionMs.toLong().coerceAtLeast(0))
117
+ null
118
+ }
119
+
120
+ @ReactMethod
121
+ fun getStats(clientId: String, sessionId: String, promise: Promise) = withSession(clientId, sessionId, promise) {
122
+ it.statistics().toWritableMap()
123
+ }
124
+
125
+ @ReactMethod
126
+ fun getDiagnostics(clientId: String, sessionId: String, promise: Promise) = withSession(clientId, sessionId, promise) {
127
+ it.diagnostics().toWritableMap()
128
+ }
129
+
130
+ @ReactMethod
131
+ fun getStatus(clientId: String, promise: Promise) {
132
+ try {
133
+ promise.resolve(clients.requireClient(clientId).client.status.toWritableMap())
134
+ } catch (error: Throwable) {
135
+ promise.reject("MESHSTREAM_STATUS_FAILED", error.message, error)
136
+ }
137
+ }
138
+
139
+ @ReactMethod
140
+ fun closeSession(clientId: String, sessionId: String, promise: Promise) {
141
+ try {
142
+ val bridgeClient = clients.requireClient(clientId)
143
+ bridgeClient.sessions.remove(sessionId)?.let { session ->
144
+ bridgeClient.pendingEvents.remove(session.sessionId)
145
+ session.close()
146
+ }
147
+ promise.resolve(null)
148
+ } catch (error: Throwable) {
149
+ promise.reject("MESHSTREAM_CLOSE_SESSION_FAILED", error.message, error)
150
+ }
151
+ }
152
+
153
+ @ReactMethod
154
+ fun closeClient(clientId: String, promise: Promise) {
155
+ try {
156
+ clients.remove(clientId)?.let { bridgeClient ->
157
+ bridgeClient.sessions.values.forEach { it.close() }
158
+ bridgeClient.sessions.clear()
159
+ bridgeClient.pendingEvents.clear()
160
+ bridgeClient.client.close()
161
+ }
162
+ promise.resolve(null)
163
+ } catch (error: Throwable) {
164
+ promise.reject("MESHSTREAM_CLOSE_CLIENT_FAILED", error.message, error)
165
+ }
166
+ }
167
+
168
+ override fun invalidate() {
169
+ clients.values.forEach { bridgeClient ->
170
+ bridgeClient.sessions.values.forEach { it.close() }
171
+ bridgeClient.pendingEvents.clear()
172
+ bridgeClient.client.close()
173
+ }
174
+ clients.clear()
175
+ super.invalidate()
176
+ }
177
+
178
+ private fun withSession(clientId: String, sessionId: String, promise: Promise, operation: (PlaybackSession) -> Any?) {
179
+ try {
180
+ promise.resolve(operation(clients.requireClient(clientId).sessions.requireSession(sessionId)))
181
+ } catch (error: Throwable) {
182
+ promise.reject("MESHSTREAM_SESSION_OPERATION_FAILED", error.message, error)
183
+ }
184
+ }
185
+
186
+ private fun createEventListener(clientId: String, bridgeClient: BridgeClient) = object : P2pEventListener {
187
+ private fun sessionId(nativeSessionId: Long): String? = bridgeClient.sessions.entries
188
+ .firstOrNull { it.value.sessionId == nativeSessionId }?.key
189
+
190
+ private fun emit(nativeSessionId: Long, type: String, extras: WritableMap.() -> Unit = {}) {
191
+ val jsSessionId = sessionId(nativeSessionId)
192
+ if (jsSessionId == null) {
193
+ bufferPendingEvent(bridgeClient, nativeSessionId, PendingEvent(type, extras))
194
+ return
195
+ }
196
+ sendSessionEvent(clientId, jsSessionId, type, extras)
197
+ }
198
+
199
+ override fun onStatistics(sessionId: Long, stats: P2pStats) = emit(sessionId, "statistics") {
200
+ putMap("statistics", stats.toWritableMap())
201
+ }
202
+
203
+ override fun onPeerCountChanged(sessionId: Long, peerCount: Int) = emit(sessionId, "peerCountChanged") {
204
+ putInt("peerCount", peerCount)
205
+ }
206
+
207
+ override fun onPlaybackPrepared(sessionId: Long, playbackUrl: String) = emit(sessionId, "playbackPrepared") {
208
+ putString("playbackUrl", playbackUrl)
209
+ }
210
+
211
+ override fun onP2pReady(sessionId: Long) = emit(sessionId, "p2pReady")
212
+
213
+ override fun onP2pUnavailable(sessionId: Long, reason: P2pUnavailableReason) = emit(sessionId, "p2pUnavailable") {
214
+ putString("reason", reason.name)
215
+ }
216
+
217
+ override fun onTrackerError(sessionId: Long, totalErrors: Long) = emit(sessionId, "trackerError") {
218
+ putDouble("totalErrors", totalErrors.toDouble())
219
+ }
220
+
221
+ override fun onPeerConnectError(sessionId: Long, totalErrors: Long) = emit(sessionId, "peerConnectError") {
222
+ putDouble("totalErrors", totalErrors.toDouble())
223
+ }
224
+
225
+ override fun onCdnFallback(sessionId: Long, bytes: Long) = emit(sessionId, "cdnFallback") {
226
+ putDouble("bytes", bytes.toDouble())
227
+ }
228
+ }
229
+
230
+ private fun bufferPendingEvent(bridgeClient: BridgeClient, nativeSessionId: Long, event: PendingEvent) {
231
+ synchronized(bridgeClient.pendingEvents) {
232
+ val events = bridgeClient.pendingEvents[nativeSessionId]
233
+ if (events != null) {
234
+ if (events.size < MAX_PENDING_EVENTS_PER_SESSION) events += event
235
+ return
236
+ }
237
+ if (bridgeClient.pendingEvents.size >= MAX_PENDING_EVENT_SESSIONS) return
238
+ bridgeClient.pendingEvents[nativeSessionId] = mutableListOf(event)
239
+ }
240
+ }
241
+
242
+ private fun flushPendingEvents(
243
+ clientId: String,
244
+ jsSessionId: String,
245
+ session: PlaybackSession,
246
+ bridgeClient: BridgeClient,
247
+ ) {
248
+ val events = synchronized(bridgeClient.pendingEvents) {
249
+ bridgeClient.pendingEvents.remove(session.sessionId)?.toList().orEmpty()
250
+ }
251
+ events.forEach { event -> sendSessionEvent(clientId, jsSessionId, event.type, event.extras) }
252
+ }
253
+
254
+ private fun sendSessionEvent(
255
+ clientId: String,
256
+ sessionId: String,
257
+ type: String,
258
+ extras: WritableMap.() -> Unit,
259
+ ) {
260
+ sendEvent(Arguments.createMap().apply {
261
+ putString("type", type)
262
+ putString("clientId", clientId)
263
+ putString("sessionId", sessionId)
264
+ extras()
265
+ })
266
+ }
267
+
268
+ private fun sendEvent(event: WritableMap) {
269
+ if (reactApplicationContext.hasActiveReactInstance()) {
270
+ reactApplicationContext
271
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
272
+ .emit(EVENT_NAME, event)
273
+ }
274
+ }
275
+
276
+ private fun ConcurrentHashMap<String, BridgeClient>.requireClient(clientId: String): BridgeClient =
277
+ this[clientId] ?: error("Unknown or closed MeshStream client: $clientId")
278
+
279
+ private fun ConcurrentHashMap<String, PlaybackSession>.requireSession(sessionId: String): PlaybackSession =
280
+ this[sessionId] ?: error("Unknown or closed MeshStream playback session: $sessionId")
281
+
282
+ private fun ReadableMap.requiredString(name: String): String = optionalString(name)
283
+ ?.takeIf { it.isNotBlank() }
284
+ ?: error("$name must not be empty")
285
+
286
+ private fun ReadableMap.optionalString(name: String): String? =
287
+ if (hasKey(name) && !isNull(name) && getType(name) == ReadableType.String) getString(name)?.trim() else null
288
+
289
+ private fun ReadableMap.optionalBoolean(name: String): Boolean? =
290
+ if (hasKey(name) && !isNull(name) && getType(name) == ReadableType.Boolean) getBoolean(name) else null
291
+
292
+ private fun ReadableMap.stringMap(name: String): Map<String, String> {
293
+ if (!hasKey(name) || isNull(name) || getType(name) != ReadableType.Map) return emptyMap()
294
+ val result = linkedMapOf<String, String>()
295
+ val map = getMap(name) ?: return emptyMap()
296
+ val iterator: ReadableMapKeySetIterator = map.keySetIterator()
297
+ while (iterator.hasNextKey()) {
298
+ val key = iterator.nextKey()
299
+ if (map.getType(key) == ReadableType.String) map.getString(key)?.let { result[key] = it }
300
+ }
301
+ return result
302
+ }
303
+
304
+ private fun PlaybackSession.toWritableMap(clientId: String, sessionId: String): WritableMap = Arguments.createMap().apply {
305
+ putString("clientId", clientId)
306
+ putString("sessionId", sessionId)
307
+ putString("playbackUrl", playbackUrl)
308
+ putString("playbackMode", playbackMode.name)
309
+ fallbackReason?.let { putString("fallbackReason", it.name) }
310
+ }
311
+
312
+ private fun P2pSdkStatus.toWritableMap(): WritableMap = Arguments.createMap().apply {
313
+ putBoolean("accountEnabled", accountEnabled)
314
+ putBoolean("applicationEnabled", applicationEnabled)
315
+ putBoolean("keyEnabled", keyEnabled)
316
+ putBoolean("platformEnabled", platformEnabled)
317
+ putBoolean("effectiveP2pEnabled", effectiveP2pEnabled)
318
+ putString("reason", reason.name)
319
+ configVersion?.let { putString("configVersion", it) }
320
+ edgeRegion?.let { putString("edgeRegion", it) }
321
+ quotaStatus?.let { putString("quotaStatus", it) }
322
+ trackerPoolReady?.let { putBoolean("trackerPoolReady", it) }
323
+ }
324
+
325
+ private fun P2pStats.toWritableMap(): WritableMap = Arguments.createMap().apply {
326
+ putDouble("httpDownloaded", httpDownloaded.toDouble())
327
+ putDouble("p2pDownloaded", p2pDownloaded.toDouble())
328
+ putDouble("p2pUploaded", p2pUploaded.toDouble())
329
+ putDouble("httpSegmentDownloadSuccesses", httpSegmentDownloadSuccesses.toDouble())
330
+ putDouble("httpSegmentDownloadFailures", httpSegmentDownloadFailures.toDouble())
331
+ putDouble("p2pSegmentDownloadSuccesses", p2pSegmentDownloadSuccesses.toDouble())
332
+ putDouble("p2pSegmentDownloadFailures", p2pSegmentDownloadFailures.toDouble())
333
+ putDouble("p2pSegmentUploadSuccesses", p2pSegmentUploadSuccesses.toDouble())
334
+ putDouble("p2pSegmentUploadFailures", p2pSegmentUploadFailures.toDouble())
335
+ putInt("connectedPeers", connectedPeers)
336
+ putDouble("peerConnectErrors", peerConnectErrors.toDouble())
337
+ putDouble("trackerErrors", trackerErrors.toDouble())
338
+ putDouble("downloadOffloadRatio", downloadOffloadRatio)
339
+ putDouble("p2pSegmentDownloadSuccessRatio", p2pSegmentDownloadSuccessRatio)
340
+ putDouble("p2pSegmentUploadSuccessRatio", p2pSegmentUploadSuccessRatio)
341
+ }
342
+
343
+ private fun P2pDiagnosticsSnapshot.toWritableMap(): WritableMap = Arguments.createMap().apply {
344
+ putDouble("fallbackCount", fallbackCount.toDouble())
345
+ firstPeerConnectMs?.let { putDouble("firstPeerConnectMs", it.toDouble()) }
346
+ coldStart?.let {
347
+ putMap("coldStart", Arguments.createMap().apply {
348
+ putDouble("p2pDownloaded", it.p2pDownloaded.toDouble())
349
+ putDouble("httpDownloaded", it.httpDownloaded.toDouble())
350
+ })
351
+ }
352
+ lastTrackerError?.let { putString("lastTrackerError", it) }
353
+ lastPeerError?.let { putString("lastPeerError", it) }
354
+ lastErrorAtMs?.let { putDouble("lastErrorAtMs", it.toDouble()) }
355
+ putDouble("iceAttempts", iceAttempts.toDouble())
356
+ putDouble("iceSuccesses", iceSuccesses.toDouble())
357
+ putDouble("iceFailures", iceFailures.toDouble())
358
+ putDouble("iceDurationMs", iceDurationMs.toDouble())
359
+ }
360
+
361
+ companion object {
362
+ private const val MODULE_NAME = "MeshStreamP2P"
363
+ private const val EVENT_NAME = "MeshStreamP2P:event"
364
+ private const val MAX_PENDING_EVENT_SESSIONS = 64
365
+ private const val MAX_PENDING_EVENTS_PER_SESSION = 8
366
+ }
367
+ }
@@ -0,0 +1,13 @@
1
+ package net.meshstream.reactnative
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class MeshStreamP2PPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10
+ listOf(MeshStreamP2PModule(reactContext))
11
+
12
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
13
+ }
@@ -0,0 +1,4 @@
1
+ #import <React/RCTEventEmitter.h>
2
+
3
+ @interface MeshStreamP2P : RCTEventEmitter
4
+ @end