@shortkitsdk/react-native 0.1.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.
Files changed (35) hide show
  1. package/ShortKitReactNative.podspec +19 -0
  2. package/android/build.gradle.kts +34 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +249 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +32 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +769 -0
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +101 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitPackage.kt +40 -0
  8. package/app.plugin.js +1 -0
  9. package/ios/ShortKitBridge.swift +537 -0
  10. package/ios/ShortKitFeedView.swift +207 -0
  11. package/ios/ShortKitFeedViewManager.mm +29 -0
  12. package/ios/ShortKitModule.h +25 -0
  13. package/ios/ShortKitModule.mm +204 -0
  14. package/ios/ShortKitOverlayBridge.swift +91 -0
  15. package/ios/ShortKitReactNative-Bridging-Header.h +3 -0
  16. package/ios/ShortKitReactNative.podspec +19 -0
  17. package/package.json +50 -0
  18. package/plugin/build/index.d.ts +3 -0
  19. package/plugin/build/index.js +13 -0
  20. package/plugin/build/withShortKitAndroid.d.ts +8 -0
  21. package/plugin/build/withShortKitAndroid.js +32 -0
  22. package/plugin/build/withShortKitIOS.d.ts +8 -0
  23. package/plugin/build/withShortKitIOS.js +29 -0
  24. package/react-native.config.js +8 -0
  25. package/src/OverlayManager.tsx +87 -0
  26. package/src/ShortKitContext.ts +51 -0
  27. package/src/ShortKitFeed.tsx +203 -0
  28. package/src/ShortKitProvider.tsx +526 -0
  29. package/src/index.ts +26 -0
  30. package/src/serialization.ts +95 -0
  31. package/src/specs/NativeShortKitModule.ts +201 -0
  32. package/src/specs/ShortKitFeedViewNativeComponent.ts +13 -0
  33. package/src/types.ts +167 -0
  34. package/src/useShortKit.ts +20 -0
  35. package/src/useShortKitPlayer.ts +29 -0
@@ -0,0 +1,101 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.content.Context
4
+ import android.graphics.Color
5
+ import android.view.GestureDetector
6
+ import android.view.MotionEvent
7
+ import android.widget.FrameLayout
8
+ import com.facebook.react.bridge.Arguments
9
+ import com.shortkit.ContentItem
10
+ import com.shortkit.FeedOverlay
11
+ import com.shortkit.ShortKitPlayer
12
+
13
+ /**
14
+ * A transparent [FrameLayout] that implements [FeedOverlay] and bridges
15
+ * overlay lifecycle calls to JS events via [ShortKitModule].
16
+ *
17
+ * The actual overlay UI is rendered by React Native on the JS side through
18
+ * the `OverlayManager` component. This view simply relays the SDK lifecycle
19
+ * events so the JS overlay knows when to configure, activate, reset, etc.
20
+ *
21
+ * Android equivalent of iOS `ShortKitOverlayBridge.swift`.
22
+ */
23
+ class ShortKitOverlayBridge(context: Context) : FrameLayout(context), FeedOverlay {
24
+
25
+ // -----------------------------------------------------------------------
26
+ // State
27
+ // -----------------------------------------------------------------------
28
+
29
+ /**
30
+ * Stores the last configured [ContentItem] so we can pass it with
31
+ * lifecycle events that don't receive the item as a parameter.
32
+ */
33
+ private var currentItem: ContentItem? = null
34
+
35
+ // -----------------------------------------------------------------------
36
+ // Gestures
37
+ // -----------------------------------------------------------------------
38
+
39
+ private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
40
+ override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
41
+ ShortKitModule.shared?.emitOverlayEvent("onOverlayTap", Arguments.createMap())
42
+ return true
43
+ }
44
+
45
+ override fun onDoubleTap(e: MotionEvent): Boolean {
46
+ val params = Arguments.createMap().apply {
47
+ putDouble("x", e.x.toDouble())
48
+ putDouble("y", e.y.toDouble())
49
+ }
50
+ ShortKitModule.shared?.emitOverlayEvent("onOverlayDoubleTap", params)
51
+ return true
52
+ }
53
+ })
54
+
55
+ // -----------------------------------------------------------------------
56
+ // Init
57
+ // -----------------------------------------------------------------------
58
+
59
+ init {
60
+ setBackgroundColor(Color.TRANSPARENT)
61
+ isClickable = true
62
+ }
63
+
64
+ override fun onTouchEvent(event: MotionEvent): Boolean {
65
+ return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
66
+ }
67
+
68
+ // -----------------------------------------------------------------------
69
+ // FeedOverlay
70
+ // -----------------------------------------------------------------------
71
+
72
+ override fun attach(player: ShortKitPlayer) {
73
+ // No-op on the native side. The JS overlay subscribes to player
74
+ // state via the TurboModule's flow publishers.
75
+ }
76
+
77
+ override fun configure(item: ContentItem) {
78
+ currentItem = item
79
+ ShortKitModule.shared?.emitOverlayEvent("onOverlayConfigure", item)
80
+ }
81
+
82
+ override fun resetPlaybackProgress() {
83
+ val item = currentItem ?: return
84
+ ShortKitModule.shared?.emitOverlayEvent("onOverlayReset", item)
85
+ }
86
+
87
+ override fun activatePlayback() {
88
+ val item = currentItem ?: return
89
+ ShortKitModule.shared?.emitOverlayEvent("onOverlayActivate", item)
90
+ }
91
+
92
+ override fun fadeOutForTransition() {
93
+ val item = currentItem ?: return
94
+ ShortKitModule.shared?.emitOverlayEvent("onOverlayFadeOut", item)
95
+ }
96
+
97
+ override fun restoreFromTransition() {
98
+ val item = currentItem ?: return
99
+ ShortKitModule.shared?.emitOverlayEvent("onOverlayRestore", item)
100
+ }
101
+ }
@@ -0,0 +1,40 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import com.facebook.react.TurboReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+ import com.facebook.react.uimanager.ViewManager
9
+
10
+ class ShortKitPackage : TurboReactPackage() {
11
+
12
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
13
+ return if (name == ShortKitModule.NAME) {
14
+ ShortKitModule(reactContext)
15
+ } else {
16
+ null
17
+ }
18
+ }
19
+
20
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
21
+ return ReactModuleInfoProvider {
22
+ mapOf(
23
+ ShortKitModule.NAME to ReactModuleInfo(
24
+ ShortKitModule.NAME, // name
25
+ ShortKitModule.NAME, // className
26
+ false, // canOverrideExistingModule
27
+ false, // needsEagerInit
28
+ false, // isCxxModule
29
+ true // isTurboModule
30
+ )
31
+ )
32
+ }
33
+ }
34
+
35
+ override fun createViewManagers(
36
+ reactContext: ReactApplicationContext
37
+ ): List<ViewManager<*, *>> {
38
+ return listOf(ShortKitFeedViewManager())
39
+ }
40
+ }
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./plugin/build');
@@ -0,0 +1,537 @@
1
+ import Foundation
2
+ import Combine
3
+ import ShortKit
4
+
5
+ /// Swift bridge between the ShortKit SDK and the Obj-C++ TurboModule.
6
+ ///
7
+ /// Holds the `ShortKit` instance, subscribes to all Combine publishers on
8
+ /// `ShortKitPlayer`, and forwards events to JS via the delegate protocol.
9
+ @objc public class ShortKitBridge: NSObject, ShortKitDelegate {
10
+
11
+ // MARK: - Shared instance (accessed by Fabric view manager in Task 13)
12
+
13
+ @objc public static var shared: ShortKitBridge?
14
+
15
+ // MARK: - Properties
16
+
17
+ private var shortKit: ShortKit?
18
+ private var cancellables = Set<AnyCancellable>()
19
+ private weak var delegate: ShortKitBridgeDelegateProtocol?
20
+
21
+ // MARK: - Init
22
+
23
+ @objc public init(
24
+ apiKey: String,
25
+ config configJSON: String,
26
+ clientAppName: String?,
27
+ clientAppVersion: String?,
28
+ customDimensions customDimensionsJSON: String?,
29
+ delegate: ShortKitBridgeDelegateProtocol
30
+ ) {
31
+ self.delegate = delegate
32
+ super.init()
33
+
34
+ let feedConfig = Self.parseFeedConfig(configJSON)
35
+ let dimensions = Self.parseCustomDimensions(customDimensionsJSON)
36
+
37
+ let sdk = ShortKit(
38
+ apiKey: apiKey,
39
+ config: feedConfig,
40
+ clientAppName: clientAppName,
41
+ clientAppVersion: clientAppVersion,
42
+ customDimensions: dimensions
43
+ )
44
+ sdk.delegate = self
45
+ self.shortKit = sdk
46
+
47
+ ShortKitBridge.shared = self
48
+
49
+ subscribeToPublishers(sdk.player)
50
+ }
51
+
52
+ // MARK: - Teardown
53
+
54
+ @objc public func teardown() {
55
+ cancellables.removeAll()
56
+ shortKit = nil
57
+ if ShortKitBridge.shared === self {
58
+ ShortKitBridge.shared = nil
59
+ }
60
+ }
61
+
62
+ // MARK: - Public accessors for Fabric view
63
+
64
+ /// The underlying ShortKit instance. Used by the Fabric view manager.
65
+ public var sdk: ShortKit? { shortKit }
66
+
67
+ // MARK: - Lifecycle
68
+
69
+ @objc public func setUserId(_ userId: String) {
70
+ shortKit?.setUserId(userId)
71
+ }
72
+
73
+ @objc public func clearUserId() {
74
+ shortKit?.clearUserId()
75
+ }
76
+
77
+ /// Called when the app backgrounds. The ShortKit SDK handles app lifecycle
78
+ /// internally (AudioSessionManager, PlayerManager), so we only pause here
79
+ /// as a safety net. The SDK's internal handling will also fire.
80
+ @objc public func onPause() {
81
+ shortKit?.player.pause()
82
+ }
83
+
84
+ /// Called when the app foregrounds. We do NOT auto-play here because:
85
+ /// 1. The user may have manually paused before backgrounding.
86
+ /// 2. The ShortKit SDK's internal lifecycle management already resumes
87
+ /// playback when appropriate.
88
+ @objc public func onResume() {
89
+ // No-op: let the SDK's internal lifecycle handle resume
90
+ }
91
+
92
+ // MARK: - Player Commands
93
+
94
+ @objc public func play() {
95
+ shortKit?.player.play()
96
+ }
97
+
98
+ /// Named `doPause` to avoid collision with NSObject.
99
+ @objc public func doPause() {
100
+ shortKit?.player.pause()
101
+ }
102
+
103
+ @objc public func seekTo(_ seconds: Double) {
104
+ shortKit?.player.seek(to: seconds)
105
+ }
106
+
107
+ @objc public func seekAndPlayTo(_ seconds: Double) {
108
+ shortKit?.player.seekAndPlay(to: seconds)
109
+ }
110
+
111
+ @objc public func skipToNext() {
112
+ shortKit?.player.skipToNext()
113
+ }
114
+
115
+ @objc public func skipToPrevious() {
116
+ shortKit?.player.skipToPrevious()
117
+ }
118
+
119
+ @objc public func setMuted(_ muted: Bool) {
120
+ shortKit?.player.setMuted(muted)
121
+ }
122
+
123
+ @objc public func setPlaybackRate(_ rate: Double) {
124
+ shortKit?.player.setPlaybackRate(Float(rate))
125
+ }
126
+
127
+ @objc public func setCaptionsEnabled(_ enabled: Bool) {
128
+ shortKit?.player.setCaptionsEnabled(enabled)
129
+ }
130
+
131
+ @objc public func selectCaptionTrack(_ language: String) {
132
+ shortKit?.player.selectCaptionTrack(language: language)
133
+ }
134
+
135
+ @objc public func sendContentSignal(_ signal: String) {
136
+ let contentSignal: ContentSignal = signal == "positive" ? .positive : .negative
137
+ shortKit?.player.sendContentSignal(contentSignal)
138
+ }
139
+
140
+ @objc public func setMaxBitrate(_ bitrate: Double) {
141
+ shortKit?.player.setMaxBitrate(Int(bitrate))
142
+ }
143
+
144
+ // MARK: - ShortKitDelegate
145
+
146
+ public func shortKit(_ shortKit: ShortKit, didEncounterError error: ShortKitError) {
147
+ let body: [String: Any]
148
+ switch error {
149
+ case .networkError(let underlying):
150
+ body = ["code": "network_error", "message": underlying.localizedDescription]
151
+ case .playbackError(let code, let message):
152
+ body = ["code": code, "message": message]
153
+ case .authError:
154
+ body = ["code": "auth_error", "message": "Invalid API key"]
155
+ }
156
+ emitOnMain("onError", body: body)
157
+ }
158
+
159
+ public func shortKit(_ shortKit: ShortKit, didTapShareFor item: ContentItem) {
160
+ emitOnMain("onShareTapped", body: ["item": serializeContentItemToJSON(item)])
161
+ }
162
+
163
+ public func shortKit(_ shortKit: ShortKit, didRespondToSurvey surveyId: String, with option: SurveyOption) {
164
+ emitOnMain("onSurveyResponse", body: [
165
+ "surveyId": surveyId,
166
+ "optionId": option.id,
167
+ "optionText": option.text
168
+ ])
169
+ }
170
+
171
+ // MARK: - Combine Subscriptions
172
+
173
+ private func subscribeToPublishers(_ player: ShortKitPlayer) {
174
+ // Player state
175
+ player.playerState
176
+ .receive(on: DispatchQueue.main)
177
+ .sink { [weak self] state in
178
+ var body: [String: Any] = ["state": Self.playerStateString(state)]
179
+ if case .error(let msg) = state {
180
+ body["errorMessage"] = msg
181
+ }
182
+ self?.emit("onPlayerStateChanged", body: body)
183
+ }
184
+ .store(in: &cancellables)
185
+
186
+ // Current item
187
+ player.currentItem
188
+ .receive(on: DispatchQueue.main)
189
+ .sink { [weak self] item in
190
+ guard let item else { return }
191
+ self?.emit("onCurrentItemChanged", body: Self.contentItemDict(item))
192
+ }
193
+ .store(in: &cancellables)
194
+
195
+ // Time updates
196
+ player.time
197
+ .receive(on: DispatchQueue.main)
198
+ .sink { [weak self] time in
199
+ self?.emit("onTimeUpdate", body: [
200
+ "current": time.current,
201
+ "duration": time.duration,
202
+ "buffered": time.buffered
203
+ ])
204
+ }
205
+ .store(in: &cancellables)
206
+
207
+ // Muted state
208
+ player.isMuted
209
+ .receive(on: DispatchQueue.main)
210
+ .sink { [weak self] muted in
211
+ self?.emit("onMutedChanged", body: ["isMuted": muted])
212
+ }
213
+ .store(in: &cancellables)
214
+
215
+ // Playback rate
216
+ player.playbackRate
217
+ .receive(on: DispatchQueue.main)
218
+ .sink { [weak self] rate in
219
+ self?.emit("onPlaybackRateChanged", body: ["rate": Double(rate)])
220
+ }
221
+ .store(in: &cancellables)
222
+
223
+ // Captions enabled
224
+ player.captionsEnabled
225
+ .receive(on: DispatchQueue.main)
226
+ .sink { [weak self] enabled in
227
+ self?.emit("onCaptionsEnabledChanged", body: ["enabled": enabled])
228
+ }
229
+ .store(in: &cancellables)
230
+
231
+ // Active caption track
232
+ player.activeCaptionTrack
233
+ .receive(on: DispatchQueue.main)
234
+ .sink { [weak self] track in
235
+ guard let track else { return }
236
+ self?.emit("onActiveCaptionTrackChanged", body: [
237
+ "language": track.language,
238
+ "label": track.label,
239
+ "sourceUrl": track.url ?? ""
240
+ ])
241
+ }
242
+ .store(in: &cancellables)
243
+
244
+ // Active cue
245
+ player.activeCue
246
+ .receive(on: DispatchQueue.main)
247
+ .sink { [weak self] cue in
248
+ guard let cue else { return }
249
+ self?.emit("onActiveCueChanged", body: [
250
+ "text": cue.text,
251
+ "startTime": cue.startTime,
252
+ "endTime": cue.endTime
253
+ ])
254
+ }
255
+ .store(in: &cancellables)
256
+
257
+ // Did loop
258
+ player.didLoop
259
+ .receive(on: DispatchQueue.main)
260
+ .sink { [weak self] event in
261
+ self?.emit("onDidLoop", body: [
262
+ "contentId": event.contentId,
263
+ "loopCount": event.loopCount
264
+ ])
265
+ }
266
+ .store(in: &cancellables)
267
+
268
+ // Feed transition
269
+ player.feedTransition
270
+ .receive(on: DispatchQueue.main)
271
+ .sink { [weak self] event in
272
+ guard let self else { return }
273
+ var body: [String: Any] = [
274
+ "phase": Self.transitionPhaseString(event.phase),
275
+ "direction": Self.transitionDirectionString(event.direction)
276
+ ]
277
+ if let from = event.from {
278
+ body["fromItem"] = self.serializeContentItemToJSON(from)
279
+ }
280
+ if let to = event.to {
281
+ body["toItem"] = self.serializeContentItemToJSON(to)
282
+ }
283
+ self.emit("onFeedTransition", body: body)
284
+ }
285
+ .store(in: &cancellables)
286
+
287
+ // Format change
288
+ player.formatChange
289
+ .receive(on: DispatchQueue.main)
290
+ .sink { [weak self] event in
291
+ self?.emit("onFormatChange", body: [
292
+ "contentId": event.contentId,
293
+ "fromBitrate": Double(event.fromBitrate),
294
+ "toBitrate": Double(event.toBitrate),
295
+ "fromResolution": event.fromResolution,
296
+ "toResolution": event.toResolution
297
+ ])
298
+ }
299
+ .store(in: &cancellables)
300
+
301
+ // Prefetched ahead count
302
+ player.prefetchedAheadCount
303
+ .receive(on: DispatchQueue.main)
304
+ .sink { [weak self] count in
305
+ self?.emit("onPrefetchedAheadCountChanged", body: ["count": count])
306
+ }
307
+ .store(in: &cancellables)
308
+ }
309
+
310
+ // MARK: - Event Emission Helpers
311
+
312
+ private func emit(_ name: String, body: [String: Any]) {
313
+ delegate?.emitEvent(name, body: body)
314
+ }
315
+
316
+ private func emitOnMain(_ name: String, body: [String: Any]) {
317
+ if Thread.isMainThread {
318
+ emit(name, body: body)
319
+ } else {
320
+ DispatchQueue.main.async { [weak self] in
321
+ self?.emit(name, body: body)
322
+ }
323
+ }
324
+ }
325
+
326
+ // MARK: - Overlay Lifecycle Events (called by Fabric view in Task 13)
327
+
328
+ /// Emit overlay lifecycle events from the Fabric view's overlay container.
329
+ public func emitOverlayEvent(_ name: String, item: ContentItem) {
330
+ emitOnMain(name, body: ["item": serializeContentItemToJSON(item)])
331
+ }
332
+
333
+ /// Emit a raw overlay event with an arbitrary body.
334
+ public func emitOverlayEvent(_ name: String, body: [String: Any]) {
335
+ emitOnMain(name, body: body)
336
+ }
337
+
338
+ /// Emit an article tap event.
339
+ public func emitArticleTapped(_ item: ContentItem) {
340
+ emitOnMain("onArticleTapped", body: ["item": serializeContentItemToJSON(item)])
341
+ }
342
+
343
+ /// Emit a comment tap event.
344
+ public func emitCommentTapped(_ item: ContentItem) {
345
+ emitOnMain("onCommentTapped", body: ["item": serializeContentItemToJSON(item)])
346
+ }
347
+
348
+ /// Emit an overlay share tap event.
349
+ public func emitOverlayShareTapped(_ item: ContentItem) {
350
+ emitOnMain("onOverlayShareTapped", body: ["item": serializeContentItemToJSON(item)])
351
+ }
352
+
353
+ /// Emit a save tap event.
354
+ public func emitSaveTapped(_ item: ContentItem) {
355
+ emitOnMain("onSaveTapped", body: ["item": serializeContentItemToJSON(item)])
356
+ }
357
+
358
+ /// Emit a like tap event.
359
+ public func emitLikeTapped(_ item: ContentItem) {
360
+ emitOnMain("onLikeTapped", body: ["item": serializeContentItemToJSON(item)])
361
+ }
362
+
363
+ // MARK: - Content Item Serialization
364
+
365
+ /// Serialize a ContentItem to a JSON string for bridge transport.
366
+ private func serializeContentItemToJSON(_ item: ContentItem) -> String {
367
+ guard let data = try? JSONEncoder().encode(item),
368
+ let json = String(data: data, encoding: .utf8) else {
369
+ return "{}"
370
+ }
371
+ return json
372
+ }
373
+
374
+ /// Build an NSDictionary from a ContentItem with fields matching the JS spec.
375
+ /// `captionTracks` and `customMetadata` are JSON-serialized strings.
376
+ private static func contentItemDict(_ item: ContentItem) -> [String: Any] {
377
+ var dict: [String: Any] = [
378
+ "id": item.id,
379
+ "title": item.title,
380
+ "duration": item.duration,
381
+ "streamingUrl": item.streamingUrl,
382
+ "thumbnailUrl": item.thumbnailUrl,
383
+ ]
384
+
385
+ if let description = item.description {
386
+ dict["description"] = description
387
+ }
388
+
389
+ // Caption tracks as JSON string
390
+ if let tracksData = try? JSONEncoder().encode(item.captionTracks),
391
+ let tracksJSON = String(data: tracksData, encoding: .utf8) {
392
+ dict["captionTracks"] = tracksJSON
393
+ } else {
394
+ dict["captionTracks"] = "[]"
395
+ }
396
+
397
+ // Custom metadata as JSON string
398
+ if let meta = item.customMetadata,
399
+ let metaData = try? JSONEncoder().encode(meta),
400
+ let metaJSON = String(data: metaData, encoding: .utf8) {
401
+ dict["customMetadata"] = metaJSON
402
+ }
403
+
404
+ if let author = item.author {
405
+ dict["author"] = author
406
+ }
407
+ if let articleUrl = item.articleUrl {
408
+ dict["articleUrl"] = articleUrl
409
+ }
410
+ if let commentCount = item.commentCount {
411
+ dict["commentCount"] = commentCount
412
+ }
413
+
414
+ return dict
415
+ }
416
+
417
+ // MARK: - Player State Serialization
418
+
419
+ private static func playerStateString(_ state: PlayerState) -> String {
420
+ switch state {
421
+ case .idle: return "idle"
422
+ case .loading: return "loading"
423
+ case .ready: return "ready"
424
+ case .playing: return "playing"
425
+ case .paused: return "paused"
426
+ case .seeking: return "seeking"
427
+ case .buffering: return "buffering"
428
+ case .ended: return "ended"
429
+ case .error: return "error"
430
+ }
431
+ }
432
+
433
+ private static func transitionPhaseString(_ phase: FeedTransitionEvent.Phase) -> String {
434
+ switch phase {
435
+ case .began: return "began"
436
+ case .ended: return "ended"
437
+ }
438
+ }
439
+
440
+ private static func transitionDirectionString(_ direction: FeedTransitionEvent.Direction) -> String {
441
+ switch direction {
442
+ case .forward: return "forward"
443
+ case .backward: return "backward"
444
+ }
445
+ }
446
+
447
+ // MARK: - Config Parsing
448
+
449
+ /// Parse the JSON config string from JS into a FeedConfig.
450
+ ///
451
+ /// Expected JSON structure:
452
+ /// ```json
453
+ /// {"feedHeight":"{\"type\":\"fullscreen\"}","overlay":"\"none\"",
454
+ /// "carouselMode":"\"none\"","surveyMode":"\"none\"","muteOnStart":true}
455
+ /// ```
456
+ private static func parseFeedConfig(_ json: String) -> FeedConfig {
457
+ guard let data = json.data(using: .utf8),
458
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
459
+ return FeedConfig()
460
+ }
461
+
462
+ let feedHeight = parseFeedHeight(obj["feedHeight"] as? String)
463
+ let muteOnStart = obj["muteOnStart"] as? Bool ?? true
464
+ let videoOverlay = parseVideoOverlay(obj["overlay"] as? String)
465
+
466
+ return FeedConfig(
467
+ feedHeight: feedHeight,
468
+ videoOverlay: videoOverlay,
469
+ carouselOverlay: .none,
470
+ surveyOverlay: .none,
471
+ adOverlay: .none,
472
+ muteOnStart: muteOnStart
473
+ )
474
+ }
475
+
476
+ /// Parse a double-stringified overlay JSON into a `VideoOverlayMode`.
477
+ ///
478
+ /// Examples:
479
+ /// - `"\"none\""` → `.none`
480
+ /// - `"{\"type\":\"custom\"}"` → `.custom { ShortKitOverlayBridge() }`
481
+ private static func parseVideoOverlay(_ json: String?) -> VideoOverlayMode {
482
+ guard let json,
483
+ let data = json.data(using: .utf8),
484
+ let parsed = try? JSONSerialization.jsonObject(with: data) else {
485
+ return .none
486
+ }
487
+
488
+ // Case 1: Simple string "none"
489
+ if let str = parsed as? String, str == "none" {
490
+ return .none
491
+ }
492
+
493
+ // Case 2: Object with "type" key
494
+ if let obj = parsed as? [String: Any],
495
+ let type = obj["type"] as? String,
496
+ type == "custom" {
497
+ return .custom { @Sendable in
498
+ let overlay = ShortKitOverlayBridge()
499
+ overlay.bridge = ShortKitBridge.shared
500
+ return overlay
501
+ }
502
+ }
503
+
504
+ return .none
505
+ }
506
+
507
+ /// Parse a double-stringified feedHeight JSON.
508
+ /// e.g. `"{\"type\":\"fullscreen\"}"` or `"{\"type\":\"percentage\",\"value\":0.8}"`
509
+ private static func parseFeedHeight(_ json: String?) -> FeedHeight {
510
+ guard let json,
511
+ let data = json.data(using: .utf8),
512
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
513
+ let type = obj["type"] as? String else {
514
+ return .fullscreen
515
+ }
516
+
517
+ switch type {
518
+ case "percentage":
519
+ if let value = obj["value"] as? Double {
520
+ return .percentage(CGFloat(value))
521
+ }
522
+ return .fullscreen
523
+ default:
524
+ return .fullscreen
525
+ }
526
+ }
527
+
528
+ /// Parse optional custom dimensions JSON string into dictionary.
529
+ private static func parseCustomDimensions(_ json: String?) -> [String: String]? {
530
+ guard let json,
531
+ let data = json.data(using: .utf8),
532
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
533
+ return nil
534
+ }
535
+ return dict
536
+ }
537
+ }