@jwplayer/jwplayer-react-native 1.2.0 → 1.3.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 (32) hide show
  1. package/README.md +114 -21
  2. package/RNJWPlayer.podspec +1 -1
  3. package/android/build.gradle +14 -1
  4. package/android/src/main/java/com/jwplayer/rnjwplayer/RNJWPlayerModule.java +19 -4
  5. package/android/src/main/java/com/jwplayer/rnjwplayer/RNJWPlayerView.java +270 -105
  6. package/android/src/main/java/com/jwplayer/rnjwplayer/Util.java +13 -1
  7. package/badges/version.svg +1 -1
  8. package/docs/CONFIG-REFERENCE.md +747 -0
  9. package/docs/MIGRATION-GUIDE.md +617 -0
  10. package/docs/PLATFORM-DIFFERENCES.md +693 -0
  11. package/docs/props.md +15 -3
  12. package/index.d.ts +207 -249
  13. package/ios/RNJWPlayer/RNJWPlayerView.swift +278 -21
  14. package/ios/RNJWPlayer/RNJWPlayerViewController.swift +33 -16
  15. package/package.json +2 -2
  16. package/types/advertising.d.ts +514 -0
  17. package/types/index.d.ts +21 -0
  18. package/types/legacy.d.ts +82 -0
  19. package/types/platform-specific.d.ts +641 -0
  20. package/types/playlist.d.ts +410 -0
  21. package/types/unified-config.d.ts +591 -0
  22. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  23. package/android/.gradle/8.9/checksums/md5-checksums.bin +0 -0
  24. package/android/.gradle/8.9/checksums/sha1-checksums.bin +0 -0
  25. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  26. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  27. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  28. package/android/.gradle/8.9/gc.properties +0 -0
  29. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  30. package/android/.gradle/buildOutputCleanup/cache.properties +0 -2
  31. package/android/.gradle/vcs-1/gc.properties +0 -0
  32. package/docs/types.md +0 -254
@@ -310,8 +310,17 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
310
310
  private var pendingPlayerConfig: [String: Any]?
311
311
  private var playerConfigTimeout: Timer?
312
312
  private let maxPendingTime: TimeInterval = 5.0 // Maximum time to wait for PiP to close
313
+ private var isRecreatingPlayer: Bool = false // Prevents re-entrant calls during recreation
313
314
 
314
315
  @objc func recreatePlayerWithConfig(_ config: [String: Any]) {
316
+ // Prevent re-entrant calls while player is being recreated
317
+ if isRecreatingPlayer {
318
+ print("Warning: Player recreation already in progress, queueing this config change")
319
+ // Override any pending config with the latest one
320
+ pendingPlayerConfig = config
321
+ return
322
+ }
323
+
315
324
  // Cancel any existing pending configuration
316
325
  if pendingPlayerConfig != nil {
317
326
  print("Warning: Overriding pending content switch")
@@ -325,7 +334,7 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
325
334
  return
326
335
  }
327
336
 
328
- // 1. Handle PiP state
337
+ // 1. Handle PiP state (must exit PiP before any changes)
329
338
  var isPipActive = false
330
339
  var pipController: AVPictureInPictureController?
331
340
 
@@ -337,11 +346,10 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
337
346
  isPipActive = pipController?.isPictureInPictureActive ?? false
338
347
  }
339
348
 
340
- // 2. If in PiP, store the config and exit PiP
341
349
  if isPipActive {
342
350
  guard let pipController = pipController else {
343
351
  print("Warning: PiP appears active but controller is nil, proceeding with direct switch")
344
- completePlayerReconfiguration(config: config)
352
+ proceedWithConfigChange(config: config)
345
353
  return
346
354
  }
347
355
 
@@ -353,31 +361,255 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
353
361
  print("Warning: PiP close timeout reached, forcing content switch")
354
362
  if let pendingConfig = self.pendingPlayerConfig {
355
363
  self.pendingPlayerConfig = nil
356
- self.completePlayerReconfiguration(config: pendingConfig)
364
+ self.proceedWithConfigChange(config: pendingConfig)
357
365
  }
358
366
  }
359
367
 
360
368
  // Attempt to stop PiP
361
369
  pipController.stopPictureInPicture()
370
+ return
371
+ }
372
+
373
+ // 2. Handle fullscreen state (only exit if we need to recreate)
374
+ let isFullscreen = playerViewController?.isFullScreen ?? false
375
+
376
+ if isFullscreen && requiresPlayerRecreation(config) {
377
+ // Only exit fullscreen if we need to recreate the player
378
+ // For reconfiguration, fullscreen state will be preserved automatically
379
+ print("Fullscreen active and recreation needed - exiting fullscreen first")
380
+ pendingPlayerConfig = config
362
381
 
363
- } else {
364
- completePlayerReconfiguration(config: config)
382
+ // Set a timeout to prevent infinite waiting
383
+ playerConfigTimeout = Timer.scheduledTimer(withTimeInterval: maxPendingTime, repeats: false) { [weak self] _ in
384
+ guard let self = self else { return }
385
+ print("Warning: Fullscreen exit timeout reached, forcing content switch")
386
+ if let pendingConfig = self.pendingPlayerConfig {
387
+ self.pendingPlayerConfig = nil
388
+ self.proceedWithConfigChange(config: pendingConfig)
389
+ }
390
+ }
391
+
392
+ // Exit fullscreen
393
+ playerViewController?.dismiss(animated: true) { [weak self] in
394
+ guard let self = self else { return }
395
+ self.playerConfigTimeout?.invalidate()
396
+ self.playerConfigTimeout = nil
397
+ if let pendingConfig = self.pendingPlayerConfig {
398
+ self.pendingPlayerConfig = nil
399
+ self.proceedWithConfigChange(config: pendingConfig)
400
+ }
401
+ }
402
+ return
365
403
  }
404
+
405
+ // 3. No special state handling needed - proceed with config change
406
+ // If in fullscreen and just reconfiguring, state will be preserved
407
+ proceedWithConfigChange(config: config)
366
408
  }
367
409
 
368
- private func completePlayerReconfiguration(config: [String: Any]) {
410
+ /// Determines the appropriate way to apply the config change
411
+ private func proceedWithConfigChange(config: [String: Any]) {
369
412
  // Clear any pending timeout
370
413
  playerConfigTimeout?.invalidate()
371
414
  playerConfigTimeout = nil
372
415
 
373
416
  // Ensure we're on the main thread
374
- if !Thread.isMainThread {
417
+ guard Thread.isMainThread else {
418
+ DispatchQueue.main.async { [weak self] in
419
+ self?.proceedWithConfigChange(config: config)
420
+ }
421
+ return
422
+ }
423
+
424
+ // Decide: recreate or reconfigure?
425
+ if requiresPlayerRecreation(config) {
426
+ print("Player recreation required - performing full recreation")
427
+ completePlayerRecreation(config: config)
428
+ } else {
429
+ print("Reconfiguring existing player without recreation (optimized)")
430
+ reconfigurePlayer(config: config)
431
+ }
432
+ }
433
+
434
+ /// Determines if a config change requires full player recreation.
435
+ /// Only returns true for changes that genuinely cannot be handled by reconfiguration.
436
+ private func requiresPlayerRecreation(_ config: [String: Any]) -> Bool {
437
+ // If no player exists, we need to create one
438
+ guard playerViewController != nil || playerView != nil else {
439
+ return true
440
+ }
441
+
442
+ // If no previous config, this is first-time setup
443
+ guard let currentConfig = currentConfig else {
444
+ return true
445
+ }
446
+
447
+ // Check for license changes (requires recreation)
448
+ let newLicense = config["license"] as? String
449
+ let oldLicense = currentConfig["license"] as? String
450
+
451
+ if newLicense != oldLicense {
452
+ if newLicense != nil && oldLicense != nil {
453
+ print("License changed from '\(oldLicense!)' to '\(newLicense!)' - recreation required")
454
+ return true
455
+ } else if newLicense == nil || oldLicense == nil {
456
+ print("License presence changed - recreation required")
457
+ return true
458
+ }
459
+ }
460
+
461
+ // Check for viewOnly mode changes (playerView vs playerViewController)
462
+ let newViewOnly = config["viewOnly"] as? Bool ?? false
463
+ let oldViewOnly = currentConfig["viewOnly"] as? Bool ?? false
464
+ if newViewOnly != oldViewOnly {
465
+ print("viewOnly mode changed - recreation required")
466
+ return true
467
+ }
468
+
469
+ // All other changes can be handled by reconfiguration (optimized path!)
470
+ print("iOS: Using reconfiguration path (optimized)")
471
+ return false
472
+ }
473
+
474
+ /// Reconfigures the existing player with new settings without recreation.
475
+ /// This is the preferred path for config updates as it preserves the player instance
476
+ /// and maintains state (fullscreen, etc.), following JWPlayer SDK's design intent.
477
+ private func reconfigurePlayer(config: [String: Any]) {
478
+ guard let playerViewController = playerViewController else {
479
+ // No player exists, need to create
480
+ print("No player exists - falling back to recreation")
481
+ completePlayerRecreation(config: config)
482
+ return
483
+ }
484
+
485
+ // Prevent re-entrant calls during reconfiguration (Issue #192 - Error 180001)
486
+ if isRecreatingPlayer {
487
+ print("Warning: Reconfiguration already in progress, queueing this config change")
488
+ pendingPlayerConfig = config
489
+ return
490
+ }
491
+
492
+ isRecreatingPlayer = true
493
+
494
+ // Preserve state
495
+ let wasFullscreen = playerViewController.isFullScreen
496
+ let currentState = playerViewController.player.getState()
497
+ let wasPlaying = currentState == .playing
498
+
499
+ // Stop playback before reconfiguration (prevents issues)
500
+ playerViewController.player.stop()
501
+
502
+ // Parse config early (before setting license) to check if it's valid
503
+ let forceLegacyConfig = config["forceLegacyConfig"] as? Bool ?? false
504
+ let playlistItemCallback = config["playlistItemCallbackEnabled"] as? Bool ?? false
505
+
506
+ // Set license FIRST (before parsing config fully)
507
+ let license = config["license"] as? String
508
+ self.setLicense(license: license)
509
+
510
+ // Handle audio session for background/PiP
511
+ if let bae = config["backgroundAudioEnabled"] as? Bool, let pe = config["pipEnabled"] as? Bool {
512
+ backgroundAudioEnabled = bae
513
+ pipEnabled = pe
514
+ }
515
+
516
+ if backgroundAudioEnabled || pipEnabled {
517
+ let category = config["category"] != nil ? config["category"] as? String : "playback"
518
+ let categoryOptions = config["categoryOptions"] as? [String]
519
+ let mode = config["mode"] as? String
520
+ self.initAudioSession(category: category, categoryOptions: categoryOptions, mode: mode)
521
+ } else {
522
+ self.deinitAudioSession()
523
+ }
524
+
525
+ // Handle DRM parameters
526
+ processSpcUrl = config["processSpcUrl"] as? String
527
+ fairplayCertUrl = config["certificateUrl"] as? String
528
+ contentUUID = config["contentUUID"] as? String
529
+
530
+ // Handle legacy DRM in playlist
531
+ if forceLegacyConfig {
532
+ if let playlist = config["playlist"] as? [AnyObject] {
533
+ let item = playlist.first
534
+ if let itemMap = item as? [String: Any] {
535
+ if itemMap["processSpcUrl"] != nil {
536
+ processSpcUrl = itemMap["processSpcUrl"] as? String
537
+ }
538
+ if itemMap["certificateUrl"] != nil {
539
+ fairplayCertUrl = itemMap["certificateUrl"] as? String
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ // Build new configuration
546
+ do {
547
+ let playerConfig: JWPlayerConfiguration
548
+
549
+ if forceLegacyConfig {
550
+ playerConfig = try getPlayerConfiguration(config: config)
551
+ } else {
552
+ guard let data = try? JSONSerialization.data(withJSONObject: config, options: .prettyPrinted),
553
+ let jwConfig = try? JWJSONParser.config(from: data) else {
554
+ print("Failed to parse config - falling back to recreation")
555
+ completePlayerRecreation(config: config)
556
+ return
557
+ }
558
+ playerConfig = jwConfig
559
+ }
560
+
561
+ // Update stored config
562
+ currentConfig = config
563
+
564
+ // Reconfigure existing player (this is the key optimization!)
565
+ playerViewController.player.configurePlayer(with: playerConfig)
566
+
567
+ // Setup playlist item callback if needed
568
+ if playlistItemCallback {
569
+ setupPlaylistItemCallback()
570
+ }
571
+
572
+ // Fullscreen state is automatically preserved by the view controller
573
+ // No need to manually restore it
574
+ print("Player reconfigured successfully (fullscreen: \(wasFullscreen))")
575
+
576
+ // Clear the reconfiguration flag after a delay to ensure SDK completes initialization
577
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
578
+ guard let self = self else { return }
579
+ self.isRecreatingPlayer = false
580
+
581
+ // If there's a queued config change, process it now
582
+ if let queuedConfig = self.pendingPlayerConfig {
583
+ print("Processing queued config change after reconfiguration")
584
+ self.pendingPlayerConfig = nil
585
+ self.recreatePlayerWithConfig(queuedConfig)
586
+ }
587
+ }
588
+
589
+ // Optionally restart playback if it was playing
590
+ // (Usually handled by autostart in config)
591
+
592
+ } catch {
593
+ print("Error during reconfiguration: \(error) - falling back to recreation")
594
+ isRecreatingPlayer = false // Clear flag before fallback
595
+ completePlayerRecreation(config: config)
596
+ }
597
+ }
598
+
599
+ /// Performs full player recreation (destroy + create).
600
+ /// This is the expensive path and should only be used when truly necessary.
601
+ private func completePlayerRecreation(config: [String: Any]) {
602
+ // Ensure we're on the main thread
603
+ guard Thread.isMainThread else {
375
604
  DispatchQueue.main.async { [weak self] in
376
- self?.completePlayerReconfiguration(config: config)
605
+ self?.completePlayerRecreation(config: config)
377
606
  }
378
607
  return
379
608
  }
380
609
 
610
+ // Set flag to prevent re-entrant calls
611
+ isRecreatingPlayer = true
612
+
381
613
  // 1. Stop current playback safely
382
614
  if let playerView = playerView {
383
615
  let state = playerView.player.getState()
@@ -391,12 +623,26 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
391
623
  }
392
624
  }
393
625
 
394
- // 2. Reset player state
626
+ // 2. Destroy current player
395
627
  dismissPlayerViewController()
396
628
  removePlayerView()
397
629
 
398
- // 3. Set new config
630
+ // 3. Create new player with new config
399
631
  setNewConfig(config: config)
632
+
633
+ // 4. Clear the recreation flag after a delay to ensure setup completes
634
+ // The iOS SDK needs time to finish initialization
635
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
636
+ guard let self = self else { return }
637
+ self.isRecreatingPlayer = false
638
+
639
+ // If there's a queued config change, process it now
640
+ if let queuedConfig = self.pendingPlayerConfig {
641
+ print("Processing queued config change")
642
+ self.pendingPlayerConfig = nil
643
+ self.recreatePlayerWithConfig(queuedConfig)
644
+ }
645
+ }
400
646
  }
401
647
 
402
648
  func setNewConfig(config: [String : Any]) {
@@ -436,7 +682,6 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
436
682
  contentUUID = config["contentUUID"] as? String
437
683
 
438
684
  if forceLegacyConfig == true {
439
-
440
685
  // Dangerous: check playlist for processSpcUrl / fairplayCertUrl in playlist
441
686
  // Only checks first playlist item as multi-item DRM playlists are ill advised
442
687
  if let playlist = config["playlist"] as? [AnyObject] {
@@ -450,7 +695,6 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
450
695
  }
451
696
  }
452
697
  }
453
- } else {
454
698
  }
455
699
 
456
700
  do {
@@ -1263,26 +1507,30 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
1263
1507
 
1264
1508
  func appIdentifierForURL(_ url: URL, completionHandler handler: @escaping (Data?) -> Void) {
1265
1509
  guard let fairplayCertUrlString = fairplayCertUrl, let finalUrl = URL(string: fairplayCertUrlString) else {
1510
+ handler(nil)
1266
1511
  return
1267
1512
  }
1268
1513
 
1269
1514
  let request = URLRequest(url: finalUrl)
1270
1515
  let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
1271
1516
  if let error = error {
1272
- print("DRM cert request error - \(error.localizedDescription)")
1517
+ print("Error fetching FairPlay certificate: \(error.localizedDescription)")
1518
+ handler(nil)
1519
+ return
1273
1520
  }
1521
+
1274
1522
  handler(data)
1275
1523
  }
1276
1524
  task.resume()
1277
1525
  }
1278
1526
 
1279
1527
  func contentKeyWithSPCData(_ spcData: Data, completionHandler handler: @escaping (Data?, Date?, String?) -> Void) {
1280
- if processSpcUrl == nil {
1528
+ guard let processSpcUrlString = processSpcUrl else {
1529
+ handler(nil, nil, nil)
1281
1530
  return
1282
1531
  }
1283
1532
 
1284
- guard let processSpcUrl = URL(string: processSpcUrl) else {
1285
- print("Invalid processSpcUrl")
1533
+ guard let processSpcUrl = URL(string: processSpcUrlString) else {
1286
1534
  handler(nil, nil, nil)
1287
1535
  return
1288
1536
  }
@@ -1293,13 +1541,22 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
1293
1541
  ckcRequest.addValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
1294
1542
 
1295
1543
  URLSession.shared.dataTask(with: ckcRequest) { (data, response, error) in
1296
- if let httpResponse = response as? HTTPURLResponse, (error != nil || httpResponse.statusCode != 200) {
1297
- print("DRM ckc request error - %@", error?.localizedDescription ?? "Unknown error")
1544
+ if let error = error {
1545
+ print("Error fetching FairPlay license: \(error.localizedDescription)")
1298
1546
  handler(nil, nil, nil)
1299
1547
  return
1300
1548
  }
1301
1549
 
1302
- handler(data, nil, "application/octet-stream")
1550
+ if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
1551
+ handler(nil, nil, nil)
1552
+ return
1553
+ }
1554
+
1555
+ if let data = data {
1556
+ handler(data, nil, "application/octet-stream")
1557
+ } else {
1558
+ handler(nil, nil, nil)
1559
+ }
1303
1560
  }.resume()
1304
1561
  }
1305
1562
 
@@ -1326,7 +1583,7 @@ class RNJWPlayerView: UIView, JWPlayerDelegate, JWPlayerStateDelegate,
1326
1583
  if let config = pendingPlayerConfig {
1327
1584
  pendingPlayerConfig = nil
1328
1585
  DispatchQueue.main.async { [weak self] in
1329
- self?.completePlayerReconfiguration(config: config)
1586
+ self?.proceedWithConfigChange(config: config)
1330
1587
  }
1331
1588
  }
1332
1589
  }
@@ -202,40 +202,57 @@ class RNJWPlayerViewController : JWPlayerViewController, JWPlayerViewControllerF
202
202
 
203
203
  func appIdentifierForURL(_ url: URL, completionHandler handler: @escaping (Data?) -> Void) {
204
204
  guard let fairplayCertUrlString = parentView?.fairplayCertUrl, let fairplayCertUrl = URL(string: fairplayCertUrlString) else {
205
+ handler(nil)
205
206
  return
206
207
  }
207
208
 
208
209
  let request = URLRequest(url: fairplayCertUrl)
209
210
  let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
210
211
  if let error = error {
211
- print("DRM cert request error - \(error.localizedDescription)")
212
+ print("Error fetching FairPlay certificate: \(error.localizedDescription)")
213
+ handler(nil)
214
+ return
212
215
  }
216
+
213
217
  handler(data)
214
218
  }
215
219
  task.resume()
216
220
  }
217
221
 
218
222
  func contentKeyWithSPCData(_ spcData: Data, completionHandler handler: @escaping (Data?, Date?, String?) -> Void) {
219
- if parentView?.processSpcUrl == nil {
223
+ guard let processSpcUrlString = parentView?.processSpcUrl else {
224
+ handler(nil, nil, nil)
220
225
  return
221
226
  }
222
227
 
223
- if let processSpcUrl = parentView?.processSpcUrl {
224
- let ckcRequest = NSMutableURLRequest(url: NSURL(string: processSpcUrl)! as URL)
225
- ckcRequest.httpMethod = "POST"
226
- ckcRequest.httpBody = spcData
227
- ckcRequest.addValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
228
-
229
- URLSession.shared.dataTask(with: ckcRequest as URLRequest) { (data, response, error) in
230
- if let httpResponse = response as? HTTPURLResponse, (error != nil || httpResponse.statusCode != 200) {
231
- NSLog("DRM ckc request error - %@", error.debugDescription)
232
- handler(nil, nil, nil)
233
- return
234
- }
228
+ guard let processSpcUrl = URL(string: processSpcUrlString) else {
229
+ handler(nil, nil, nil)
230
+ return
231
+ }
235
232
 
233
+ let ckcRequest = NSMutableURLRequest(url: processSpcUrl)
234
+ ckcRequest.httpMethod = "POST"
235
+ ckcRequest.httpBody = spcData
236
+ ckcRequest.addValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
237
+
238
+ URLSession.shared.dataTask(with: ckcRequest as URLRequest) { (data, response, error) in
239
+ if let error = error {
240
+ print("Error fetching FairPlay license: \(error.localizedDescription)")
241
+ handler(nil, nil, nil)
242
+ return
243
+ }
244
+
245
+ if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
246
+ handler(nil, nil, nil)
247
+ return
248
+ }
249
+
250
+ if let data = data {
236
251
  handler(data, nil, "application/octet-stream")
237
- }.resume()
238
- }
252
+ } else {
253
+ handler(nil, nil, nil)
254
+ }
255
+ }.resume()
239
256
  }
240
257
 
241
258
  // MARK: - AV Picture In Picture Delegate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwplayer/jwplayer-react-native",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "React-native Android/iOS plugin for JWPlayer SDK (https://www.jwplayer.com/)",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "git+https://github.com/jwplayer/jwplayer-react-native"
17
+ "url": "git+https://github.com/jwplayer/jwplayer-react-native.git"
18
18
  },
19
19
  "keywords": [
20
20
  "react",