@bluebillywig/react-native-bb-player 8.42.10 → 8.42.15

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.
@@ -33,8 +33,13 @@ private func log(_ message: String, level: LogLevel = .debug) {
33
33
  #endif
34
34
  }
35
35
 
36
- class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
37
- private var playerController: BBPlayerViewController = BBPlayerViewController()
36
+ // MARK: - BBPlayerView
37
+ // Simplified architecture: BBPlayerView directly hosts BBNativePlayerView without intermediate view controller
38
+ // This reduces view hierarchy depth and eliminates extra layout passes for better performance/battery life
39
+
40
+ class BBPlayerView: UIView, BBNativePlayerViewDelegate {
41
+ // Direct reference to SDK player view (no intermediate view controller)
42
+ private var playerView: BBNativePlayerView?
38
43
  private var hasSetup: Bool = false
39
44
 
40
45
  // Timer for periodic time updates (opt-in for performance)
@@ -42,26 +47,26 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
42
47
  private var isPlaying: Bool = false
43
48
  private var currentDuration: Double = 0.0
44
49
  private var lastKnownTime: Double = 0.0
45
- private var playbackStartTimestamp: TimeInterval = 0
50
+ private var playbackStartTimestamp: CFTimeInterval = 0
46
51
  private var lastEmittedTime: Double = 0.0
47
52
  private var isInFullscreen: Bool = false
53
+ private var backgroundObserver: NSObjectProtocol?
54
+ private var foregroundObserver: NSObjectProtocol?
48
55
  // Independent Google Cast button for showing the cast picker
49
56
  private var independentCastButton: GCKUICastButton?
57
+ // Store parent view controller reference for SDK
58
+ private weak var parentViewController: UIViewController?
50
59
 
51
60
  // MARK: - Props (set from React Native)
52
61
 
53
62
  @objc var jsonUrl: String = "" {
54
63
  didSet {
55
- playerController.jsonUrl = jsonUrl
56
64
  setupPlayerIfNeeded()
57
65
  }
58
66
  }
59
67
 
60
68
  @objc var options: NSDictionary = [:] {
61
69
  didSet {
62
- if let optionsDict = options as? [String: Any] {
63
- playerController.options = optionsDict
64
- }
65
70
  setupPlayerIfNeeded()
66
71
  }
67
72
  }
@@ -70,12 +75,9 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
70
75
  didSet {
71
76
  log("Time updates \(enableTimeUpdates ? "enabled" : "disabled")")
72
77
 
73
- // If disabling while timer is running, stop it
74
78
  if !enableTimeUpdates && timeUpdateTimer != nil {
75
79
  stopTimeUpdates()
76
- }
77
- // If enabling while playing, start it
78
- else if enableTimeUpdates && isPlaying && timeUpdateTimer == nil {
80
+ } else if enableTimeUpdates && isPlaying && timeUpdateTimer == nil {
79
81
  startTimeUpdates()
80
82
  }
81
83
  }
@@ -124,13 +126,11 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
124
126
  @objc var onDidTriggerTimeUpdate: RCTDirectEventBlock?
125
127
  @objc var onDidTriggerApiReady: RCTDirectEventBlock?
126
128
 
127
- // Override intrinsicContentSize to tell React Native this view wants to fill available space
128
129
  override var intrinsicContentSize: CGSize {
129
130
  return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
130
131
  }
131
132
 
132
133
  private func setupPlayerIfNeeded() {
133
- // Only setup once we have both jsonUrl and we're in the window
134
134
  guard !jsonUrl.isEmpty, window != nil, !hasSetup else { return }
135
135
  hasSetup = true
136
136
  setupPlayer()
@@ -141,83 +141,52 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
141
141
 
142
142
  if window != nil {
143
143
  log("BBPlayerView.didMoveToWindow - view added to window")
144
- // Ensure this view respects its frame from React Native layout
145
- self.clipsToBounds = false // Allow settings overlay to render outside bounds
144
+ self.clipsToBounds = false
145
+
146
+ // Optimize layer for video playback - reduce compositing overhead
147
+ self.layer.isOpaque = true
148
+ self.layer.drawsAsynchronously = true
149
+ self.isOpaque = true
150
+
151
+ setupAppLifecycleObservers()
146
152
 
147
153
  // Find the parent view controller from the responder chain
148
- var parentVC: UIViewController?
149
154
  var responder = self.next
150
155
  while responder != nil {
151
156
  if let viewController = responder as? UIViewController {
152
- parentVC = viewController
157
+ parentViewController = viewController
153
158
  break
154
159
  }
155
160
  responder = responder?.next
156
161
  }
157
162
 
158
- // Add playerController as a child view controller for proper fullscreen support
159
- if let parentVC = parentVC {
160
- log("Found parent view controller: \(type(of: parentVC))")
161
- parentVC.addChild(playerController)
162
- addSubview(playerController.view)
163
-
164
- playerController.view.translatesAutoresizingMaskIntoConstraints = false
165
-
166
- NSLayoutConstraint.activate([
167
- playerController.view.topAnchor.constraint(equalTo: topAnchor),
168
- playerController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
169
- playerController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
170
- playerController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
171
- ])
172
-
173
- playerController.didMove(toParent: parentVC)
174
- playerController.setViewSize = self.setViewSize
175
- playerController.delegate = self
176
- log("Player controller added to parent VC, delegate set to BBPlayerView")
177
-
178
- // Try to setup if we have jsonUrl
179
- setupPlayerIfNeeded()
163
+ if parentViewController != nil {
164
+ log("Found parent view controller: \(type(of: parentViewController!))")
180
165
  } else {
181
166
  log("WARNING - Could not find parent view controller!", level: .warning)
182
167
  }
183
168
 
184
- } else if window == nil {
169
+ setupPlayerIfNeeded()
170
+ } else {
185
171
  log("BBPlayerView.didMoveToWindow - view removed from window, isInFullscreen: \(isInFullscreen)")
186
172
 
187
- // Stop time update timer to save CPU/battery when view is not visible
188
- // Skip this during fullscreen transitions to avoid interrupting playback
189
173
  if !isInFullscreen {
190
174
  stopTimeUpdates()
191
175
  }
192
-
193
- // Don't tear down the view controller hierarchy when the view is removed from the window.
194
- // This happens during fullscreen transitions, and the SDK needs the hierarchy intact
195
- // to properly restore the player after exiting fullscreen.
196
- // React Native will handle actual cleanup when the component unmounts.
197
176
  }
198
177
  }
199
178
 
200
- // Callback for height changes (used with allowCollapseExpand)
201
- private func setViewSize(_ size: CGSize) {
202
- // This is called by the player controller when the view size changes
203
- // In React Native, we generally let the parent handle sizing
204
- }
205
-
206
179
  // Start periodic time updates (1x per second, only if enabled)
207
180
  private func startTimeUpdates() {
208
- // Skip if time updates are disabled or timer already running
209
181
  guard enableTimeUpdates, timeUpdateTimer == nil else { return }
210
182
 
211
183
  timeUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
212
184
  guard let self = self, self.isPlaying else { return }
213
185
 
214
- // Calculate current time based on elapsed time since playback started
215
- let elapsedSeconds = Date().timeIntervalSince1970 - self.playbackStartTimestamp
186
+ let elapsedSeconds = CACurrentMediaTime() - self.playbackStartTimestamp
216
187
  let estimatedTime = self.lastKnownTime + elapsedSeconds
217
188
  let currentTime = min(estimatedTime, self.currentDuration)
218
189
 
219
- // Only emit if we have valid time values and time changed significantly (>0.5s)
220
- // This reduces unnecessary bridge calls and React re-renders
221
190
  if self.currentDuration > 0 && abs(currentTime - self.lastEmittedTime) >= 0.5 {
222
191
  self.lastEmittedTime = currentTime
223
192
  self.onDidTriggerTimeUpdate?([
@@ -228,319 +197,432 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
228
197
  }
229
198
  }
230
199
 
231
- // Stop periodic time updates
232
200
  private func stopTimeUpdates() {
233
201
  timeUpdateTimer?.invalidate()
234
202
  timeUpdateTimer = nil
235
203
  }
236
204
 
237
- // Clean up timers and views to prevent memory leaks
238
205
  deinit {
239
206
  stopTimeUpdates()
207
+ removeAppLifecycleObservers()
240
208
  independentCastButton?.removeFromSuperview()
241
209
  independentCastButton = nil
242
210
  }
243
211
 
244
- func bbPlayerViewController(
245
- _ controller: BBPlayerViewController, didTriggerEvent event: BBPlayerEvent
246
- ) {
247
- switch event {
248
- case .requestCollapse:
249
- onDidRequestCollapse?([:])
212
+ // MARK: - App Lifecycle Management
250
213
 
251
- case .requestExpand:
252
- onDidRequestExpand?([:])
214
+ private func setupAppLifecycleObservers() {
215
+ guard backgroundObserver == nil else { return }
253
216
 
254
- case .failWithError(let error):
255
- onDidFailWithError?(["payload": error as Any])
217
+ backgroundObserver = NotificationCenter.default.addObserver(
218
+ forName: UIApplication.didEnterBackgroundNotification,
219
+ object: nil,
220
+ queue: .main
221
+ ) { [weak self] _ in
222
+ guard let self = self else { return }
223
+ if self.isPlaying {
224
+ self.lastKnownTime = self.calculateCurrentTime()
225
+ }
226
+ self.stopTimeUpdates()
227
+ log("Timer paused - app entered background", level: .debug)
228
+ }
256
229
 
257
- case .requestOpenUrl(let url):
258
- onDidRequestOpenUrl?(["payload": url as Any])
230
+ foregroundObserver = NotificationCenter.default.addObserver(
231
+ forName: UIApplication.willEnterForegroundNotification,
232
+ object: nil,
233
+ queue: .main
234
+ ) { [weak self] _ in
235
+ guard let self = self else { return }
236
+ if self.isPlaying && self.enableTimeUpdates {
237
+ self.playbackStartTimestamp = CACurrentMediaTime()
238
+ self.startTimeUpdates()
239
+ log("Timer resumed - app entered foreground", level: .debug)
240
+ }
241
+ }
242
+ }
259
243
 
260
- case .setupWithJsonUrl(let url):
261
- onDidSetupWithJsonUrl?(["payload": url as Any])
244
+ private func removeAppLifecycleObservers() {
245
+ if let observer = backgroundObserver {
246
+ NotificationCenter.default.removeObserver(observer)
247
+ backgroundObserver = nil
248
+ }
249
+ if let observer = foregroundObserver {
250
+ NotificationCenter.default.removeObserver(observer)
251
+ foregroundObserver = nil
252
+ }
253
+ }
262
254
 
263
- case .adError(let error):
264
- onDidTriggerAdError?(["payload": error as Any])
255
+ // MARK: - Player Setup (Simplified - no intermediate view controller)
265
256
 
266
- case .adFinished:
267
- onDidTriggerAdFinished?([:])
257
+ func setupPlayer() {
258
+ guard let parentVC = parentViewController else {
259
+ log("ERROR - Cannot setup player without parent view controller", level: .error)
260
+ return
261
+ }
268
262
 
269
- case .adLoaded:
270
- onDidTriggerAdLoaded?([:])
263
+ log("BBPlayerView.setupPlayer() - creating player with jsonUrl: \(jsonUrl)")
271
264
 
272
- case .adLoadStart:
273
- onDidTriggerAdLoadStart?([:])
265
+ // Remove any existing player view
266
+ playerView?.removeFromSuperview()
274
267
 
275
- case .adNotFound:
276
- onDidTriggerAdNotFound?([:])
268
+ // Convert options dictionary
269
+ var optionsDict: [String: Any] = [:]
270
+ if let opts = options as? [String: Any] {
271
+ optionsDict = opts
272
+ }
277
273
 
278
- case .adQuartile1:
279
- onDidTriggerAdQuartile1?([:])
274
+ // Create player view directly using SDK factory method
275
+ // Pass the parent view controller so SDK can present fullscreen modals
276
+ playerView = BBNativePlayer.createPlayerView(
277
+ uiViewController: parentVC,
278
+ frame: bounds,
279
+ jsonUrl: jsonUrl,
280
+ options: optionsDict
281
+ )
282
+
283
+ if let pv = playerView {
284
+ // Add player view directly to BBPlayerView (no intermediate view controller)
285
+ addSubview(pv)
286
+
287
+ pv.translatesAutoresizingMaskIntoConstraints = false
288
+ NSLayoutConstraint.activate([
289
+ pv.topAnchor.constraint(equalTo: topAnchor),
290
+ pv.leadingAnchor.constraint(equalTo: leadingAnchor),
291
+ pv.trailingAnchor.constraint(equalTo: trailingAnchor),
292
+ pv.bottomAnchor.constraint(equalTo: bottomAnchor)
293
+ ])
280
294
 
281
- case .adQuartile2:
282
- onDidTriggerAdQuartile2?([:])
295
+ // Set ourselves as the delegate directly
296
+ pv.delegate = self
283
297
 
284
- case .adQuartile3:
285
- onDidTriggerAdQuartile3?([:])
298
+ log("Player view created and added directly to BBPlayerView")
299
+ } else {
300
+ log("ERROR - playerView is nil after createPlayerView!", level: .error)
301
+ }
302
+ }
286
303
 
287
- case .adStarted:
288
- onDidTriggerAdStarted?([:])
304
+ // MARK: - BBNativePlayerViewDelegate Implementation
289
305
 
290
- case .allAdsCompleted:
291
- onDidTriggerAllAdsCompleted?([:])
306
+ func bbNativePlayerView(didRequestCollapse playerView: BBNativePlayerView) {
307
+ onDidRequestCollapse?([:])
308
+ }
292
309
 
293
- case .autoPause(let why):
294
- onDidTriggerAutoPause?(["why": why as Any])
310
+ func bbNativePlayerView(didRequestExpand playerView: BBNativePlayerView) {
311
+ onDidRequestExpand?([:])
312
+ }
295
313
 
296
- case .autoPausePlay(let why):
297
- onDidTriggerAutoPausePlay?(["why": why as Any])
314
+ func bbNativePlayerView(playerView: BBNativePlayerView, didFailWithError error: String?) {
315
+ onDidFailWithError?(["payload": error as Any])
316
+ }
298
317
 
299
- case .canPlay:
300
- onDidTriggerCanPlay?([:])
318
+ func didRequestOpenUrl(url: String?) {
319
+ onDidRequestOpenUrl?(["payload": url as Any])
320
+ }
301
321
 
302
- case .customStatistics(let ident, let ev, let aux):
303
- onDidTriggerCustomStatistics?([
304
- "ident": ident,
305
- "ev": ev,
306
- "aux": aux,
307
- ])
322
+ func didSetupWithJsonUrl(url: String?) {
323
+ onDidSetupWithJsonUrl?(["payload": url as Any])
324
+ }
308
325
 
309
- case .durationChange(let duration):
310
- currentDuration = duration
311
- onDidTriggerDurationChange?(["duration": duration as Any])
326
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerAdError error: String?) {
327
+ onDidTriggerAdError?(["payload": error as Any])
328
+ }
312
329
 
313
- case .ended:
314
- isPlaying = false
315
- stopTimeUpdates()
316
- onDidTriggerEnded?([:])
330
+ func bbNativePlayerView(didTriggerAdFinished playerView: BBNativePlayerView) {
331
+ onDidTriggerAdFinished?([:])
332
+ }
317
333
 
318
- case .fullscreen:
319
- isInFullscreen = true
320
- onDidTriggerFullscreen?([:])
334
+ func bbNativePlayerView(didTriggerAdLoadStart playerView: BBNativePlayerView) {
335
+ onDidTriggerAdLoadStart?([:])
336
+ }
321
337
 
322
- case .mediaClipFailed:
323
- onDidTriggerMediaClipFailed?([:])
338
+ func bbNativePlayerView(didTriggerAdLoaded playerView: BBNativePlayerView) {
339
+ onDidTriggerAdLoaded?([:])
340
+ }
324
341
 
325
- case .mediaClipLoaded(let clipData):
326
- onDidTriggerMediaClipLoaded?(clipData.toDictionary() as [String: Any])
342
+ func bbNativePlayerView(didTriggerAdNotFound playerView: BBNativePlayerView) {
343
+ onDidTriggerAdNotFound?([:])
344
+ }
327
345
 
328
- case .modeChange(let mode):
329
- onDidTriggerModeChange?(["mode": mode as Any])
346
+ func bbNativePlayerView(didTriggerAdQuartile1 playerView: BBNativePlayerView) {
347
+ onDidTriggerAdQuartile1?([:])
348
+ }
330
349
 
331
- case .pause:
332
- isPlaying = false
333
- stopTimeUpdates()
334
- onDidTriggerPause?([:])
350
+ func bbNativePlayerView(didTriggerAdQuartile2 playerView: BBNativePlayerView) {
351
+ onDidTriggerAdQuartile2?([:])
352
+ }
335
353
 
336
- case .phaseChange(let phase):
337
- onDidTriggerPhaseChange?(["phase": (phase?.name ?? nil) as Any])
354
+ func bbNativePlayerView(didTriggerAdQuartile3 playerView: BBNativePlayerView) {
355
+ onDidTriggerAdQuartile3?([:])
356
+ }
338
357
 
339
- case .play:
340
- onDidTriggerPlay?([:])
358
+ func bbNativePlayerView(didTriggerAdStarted playerView: BBNativePlayerView) {
359
+ onDidTriggerAdStarted?([:])
360
+ }
341
361
 
342
- case .playing:
343
- isPlaying = true
344
- playbackStartTimestamp = Date().timeIntervalSince1970
345
- lastEmittedTime = 0.0 // Reset to ensure immediate time update on play
346
- lastKnownTime = calculateCurrentTime() // Update to ensure accuracy between events
347
- startTimeUpdates()
348
- onDidTriggerPlaying?([:])
362
+ func bbNativePlayerView(didTriggerAllAdsCompleted playerView: BBNativePlayerView) {
363
+ onDidTriggerAllAdsCompleted?([:])
364
+ }
349
365
 
350
- case .projectLoaded(let projectData):
351
- onDidTriggerProjectLoaded?(projectData.toDictionary() as [String: Any])
366
+ func bbNativePlayerView(didTriggerAutoPause playerView: BBNativePlayerView) {
367
+ onDidTriggerAutoPause?([:])
368
+ }
352
369
 
353
- case .retractFullscreen:
354
- isInFullscreen = false
370
+ func bbNativePlayerView(didTriggerAutoPausePlay playerView: BBNativePlayerView) {
371
+ onDidTriggerAutoPausePlay?([:])
372
+ }
373
+
374
+ func bbNativePlayerView(didTriggerCanPlay playerView: BBNativePlayerView) {
375
+ onDidTriggerCanPlay?([:])
376
+ }
355
377
 
356
- // Force rotation back to portrait when exiting fullscreen
357
- if #available(iOS 16.0, *) {
358
- if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
359
- windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
360
- log("Requested portrait rotation on retractFullscreen event", level: .info)
378
+ func bbNativePlayerView(
379
+ didTriggerCustomStatistics playerView: BBNativePlayerView, ident: String, ev: String,
380
+ aux: [String: String]
381
+ ) {
382
+ onDidTriggerCustomStatistics?([
383
+ "ident": ident,
384
+ "ev": ev,
385
+ "aux": aux,
386
+ ])
387
+ }
388
+
389
+ func bbNativePlayerView(
390
+ playerView: BBNativePlayerView, didTriggerDurationChange duration: Double
391
+ ) {
392
+ currentDuration = duration
393
+ onDidTriggerDurationChange?(["duration": duration as Any])
394
+ }
395
+
396
+ func bbNativePlayerView(didTriggerEnded playerView: BBNativePlayerView) {
397
+ isPlaying = false
398
+ stopTimeUpdates()
399
+ onDidTriggerEnded?([:])
400
+ }
401
+
402
+ func bbNativePlayerView(didTriggerFullscreen playerView: BBNativePlayerView) {
403
+ isInFullscreen = true
404
+ log("FULLSCREEN ENTRY")
405
+
406
+ // Enable landscape orientation for fullscreen
407
+ if let orientationLockClass = NSClassFromString("OrientationLock") as? NSObject.Type {
408
+ orientationLockClass.setValue(true, forKey: "isFullscreen")
409
+ }
410
+
411
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
412
+ guard let self = self, let parentVC = self.parentViewController else { return }
413
+ if let presentedVC = parentVC.presentedViewController {
414
+ if #available(iOS 16.0, *) {
415
+ presentedVC.setNeedsUpdateOfSupportedInterfaceOrientations()
361
416
  }
417
+ UIViewController.attemptRotationToDeviceOrientation()
362
418
  }
419
+ }
363
420
 
364
- onDidTriggerRetractFullscreen?([:])
421
+ onDidTriggerFullscreen?([:])
422
+ }
365
423
 
366
- case .seeked(let seekOffset):
367
- // Update lastKnownTime based on seek and reset playback timestamp
368
- lastKnownTime = seekOffset ?? 0.0
369
- playbackStartTimestamp = Date().timeIntervalSince1970
370
- lastEmittedTime = 0.0 // Reset to ensure immediate time update after seek
371
- onDidTriggerSeeked?(["payload": seekOffset as Any])
424
+ func bbNativePlayerView(didTriggerMediaClipFailed playerView: BBNativePlayerView) {
425
+ onDidTriggerMediaClipFailed?([:])
426
+ }
372
427
 
373
- case .seeking:
374
- onDidTriggerSeeking?([:])
428
+ func bbNativePlayerView(
429
+ playerView: BBNativePlayerView, didTriggerMediaClipLoaded data: MediaClip
430
+ ) {
431
+ onDidTriggerMediaClipLoaded?(data.toDictionary() as [String: Any])
432
+ }
375
433
 
376
- case .stall:
377
- onDidTriggerStall?([:])
434
+ func bbNativePlayerView(didTriggerModeChange playerView: BBNativePlayerView, mode: String?) {
435
+ onDidTriggerModeChange?(["mode": mode as Any])
436
+ }
378
437
 
379
- case .stateChange(let state):
380
- onDidTriggerStateChange?(["state": (state?.name ?? nil) as Any])
438
+ func bbNativePlayerView(didTriggerPause playerView: BBNativePlayerView) {
439
+ isPlaying = false
440
+ stopTimeUpdates()
441
+ onDidTriggerPause?([:])
442
+ }
381
443
 
382
- case .viewFinished:
383
- onDidTriggerViewFinished?([:])
444
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerPhaseChange phase: Phase?) {
445
+ onDidTriggerPhaseChange?(["phase": (phase?.name ?? nil) as Any])
446
+ }
384
447
 
385
- case .viewStarted:
386
- onDidTriggerViewStarted?([:])
448
+ func bbNativePlayerView(didTriggerPlay playerView: BBNativePlayerView) {
449
+ onDidTriggerPlay?([:])
450
+ }
387
451
 
388
- case .volumeChange(let volume):
389
- onDidTriggerVolumeChange?([
390
- "volume": volume,
391
- "muted": (volume == 0.0)
392
- ])
452
+ func bbNativePlayerView(didTriggerPlaying playerView: BBNativePlayerView) {
453
+ isPlaying = true
454
+ playbackStartTimestamp = CACurrentMediaTime()
455
+ lastEmittedTime = 0.0
456
+ lastKnownTime = calculateCurrentTime()
457
+ startTimeUpdates()
458
+ onDidTriggerPlaying?([:])
459
+ }
393
460
 
394
- case .apiReady:
395
- // Note: We create the independent cast button lazily when needed, not during init
396
- // This avoids timing issues with Google Cast SDK initialization
397
- onDidTriggerApiReady?([:])
398
- }
461
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerProjectLoaded data: Project) {
462
+ onDidTriggerProjectLoaded?(data.toDictionary() as [String: Any])
399
463
  }
400
464
 
401
- // Setup independent cast button that works alongside the SDK
402
- private func setupIndependentCastButton() {
403
- // CRITICAL: Check if Google Cast SDK is initialized before creating button
404
- if !GCKCastContext.isSharedInstanceInitialized() {
405
- log("ERROR - Cannot create cast button: Google Cast SDK not initialized yet", level: .error)
406
- return
465
+ func bbNativePlayerView(didTriggerRetractFullscreen playerView: BBNativePlayerView) {
466
+ isInFullscreen = false
467
+ log("FULLSCREEN EXIT")
468
+
469
+ // Disable landscape orientation
470
+ if let orientationLockClass = NSClassFromString("OrientationLock") as? NSObject.Type {
471
+ orientationLockClass.setValue(false, forKey: "isFullscreen")
407
472
  }
408
473
 
409
- log("Creating independent GCKUICastButton (SDK confirmed initialized)")
474
+ // Force rotation back to portrait
475
+ if #available(iOS 16.0, *) {
476
+ if let windowScene = window?.windowScene {
477
+ windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
478
+ }
479
+ } else {
480
+ UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
481
+ }
410
482
 
411
- // Create an independent GCKUICastButton separate from the SDK
412
- // This button will interact with the Google Cast SDK's session manager
413
- // The Blue Billywig SDK already listens to GCKSessionManager notifications
414
- // so it will automatically detect and handle any cast sessions we create
415
- independentCastButton = GCKUICastButton(frame: CGRect(x: -1000, y: -1000, width: 1, height: 1))
483
+ UIViewController.attemptRotationToDeviceOrientation()
416
484
 
417
- guard let castButton = independentCastButton else {
418
- log("ERROR - Failed to create independent cast button", level: .error)
419
- return
420
- }
485
+ onDidTriggerRetractFullscreen?([:])
486
+ }
421
487
 
422
- // Add it to the view hierarchy but keep it invisible and offscreen
423
- // We need it in the hierarchy so it can present dialogs, but we don't want to see it
424
- castButton.alpha = 0.0 // Completely transparent
425
- castButton.isUserInteractionEnabled = false // Can't be tapped
426
- addSubview(castButton)
488
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerSeeked seekOffset: Double) {
489
+ lastKnownTime = seekOffset
490
+ playbackStartTimestamp = CACurrentMediaTime()
491
+ lastEmittedTime = 0.0
492
+ onDidTriggerSeeked?(["payload": seekOffset as Any])
493
+ }
494
+
495
+ func bbNativePlayerView(didTriggerSeeking playerView: BBNativePlayerView) {
496
+ onDidTriggerSeeking?([:])
497
+ }
498
+
499
+ func bbNativePlayerView(didTriggerStall playerView: BBNativePlayerView) {
500
+ onDidTriggerStall?([:])
501
+ }
502
+
503
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerStateChange state: State?) {
504
+ onDidTriggerStateChange?(["state": (state?.name ?? nil) as Any])
505
+ }
427
506
 
428
- log("Successfully created and added independent GCKUICastButton to view hierarchy")
507
+ func bbNativePlayerView(didTriggerViewFinished playerView: BBNativePlayerView) {
508
+ onDidTriggerViewFinished?([:])
429
509
  }
430
510
 
431
- // Safe handler for cast button taps - uses an independent GCKUICastButton
432
- @objc private func handleCastButtonTap() {
433
- log("Cast button tapped")
511
+ func bbNativePlayerView(didTriggerViewStarted playerView: BBNativePlayerView) {
512
+ onDidTriggerViewStarted?([:])
513
+ }
514
+
515
+ func bbNativePlayerView(didTriggerVolumeChange playerView: BBNativePlayerView, volume: Double) {
516
+ onDidTriggerVolumeChange?([
517
+ "volume": volume,
518
+ "muted": (volume == 0.0)
519
+ ])
520
+ }
521
+
522
+ func bbNativePlayerView(didTriggerApiReady playerView: BBNativePlayerView) {
523
+ onDidTriggerApiReady?([:])
524
+ }
434
525
 
435
- // CRITICAL: Verify Google Cast SDK is initialized before proceeding
526
+ // MARK: - Cast Button Setup
527
+
528
+ private func setupIndependentCastButton() {
436
529
  if !GCKCastContext.isSharedInstanceInitialized() {
437
- log("ERROR - Cast button tapped but Google Cast SDK not initialized yet", level: .error)
530
+ log("ERROR - Cannot create cast button: Google Cast SDK not initialized yet", level: .error)
438
531
  return
439
532
  }
440
533
 
441
- // Create the button lazily on first use
442
- if independentCastButton == nil {
443
- setupIndependentCastButton()
444
- }
534
+ independentCastButton = GCKUICastButton(frame: CGRect(x: -1000, y: -1000, width: 1, height: 1))
445
535
 
446
536
  guard let castButton = independentCastButton else {
447
537
  log("ERROR - Failed to create independent cast button", level: .error)
448
538
  return
449
539
  }
450
540
 
451
- // Trigger the button to show the cast device picker
452
- // When a device is selected, GCKSessionManager will notify the SDK
453
- log("Triggering independent GCKUICastButton to show cast picker")
454
- castButton.sendActions(for: .touchUpInside)
541
+ castButton.alpha = 0.0
542
+ castButton.isUserInteractionEnabled = false
543
+ addSubview(castButton)
455
544
  }
456
545
 
457
- func setupPlayer() {
458
- log("BBPlayerView.setupPlayer() called with jsonUrl: \(playerController.jsonUrl)")
459
- playerController.setupPlayer()
460
- log("BBPlayerView.setupPlayer() completed")
546
+ // MARK: - Private Helper Methods
547
+
548
+ private func calculateCurrentTime() -> Double {
549
+ if isPlaying && playbackStartTimestamp > 0 {
550
+ let elapsedSeconds = CACurrentMediaTime() - playbackStartTimestamp
551
+ let estimatedTime = lastKnownTime + elapsedSeconds
552
+ return min(estimatedTime, currentDuration)
553
+ } else {
554
+ return lastKnownTime
555
+ }
461
556
  }
462
557
 
558
+ // MARK: - Public API Methods
559
+
463
560
  func adMediaHeight() -> Int? {
464
- return playerController.playerView?.player.adMediaHeight
561
+ return playerView?.player.adMediaHeight
465
562
  }
466
563
 
467
564
  func adMediaWidth() -> Int? {
468
- return playerController.playerView?.player.adMediaWidth
565
+ return playerView?.player.adMediaWidth
469
566
  }
470
567
 
471
- func adMediaClip() -> Any? {
472
- return playerController.playerView?.player.clipData?.toDictionary()
568
+ func clipData() -> Any? {
569
+ return playerView?.player.clipData?.toDictionary()
473
570
  }
474
571
 
475
572
  func controls() -> Bool? {
476
573
  return nil
477
574
  }
478
575
 
479
- // MARK: - Private Helper Methods
480
-
481
- /// Calculate estimated current time based on playback state
482
- /// iOS SDK doesn't expose direct currentTime property, so we estimate it
483
- private func calculateCurrentTime() -> Double {
484
- if isPlaying && playbackStartTimestamp > 0 {
485
- let elapsedSeconds = Date().timeIntervalSince1970 - playbackStartTimestamp
486
- let estimatedTime = lastKnownTime + elapsedSeconds
487
- return min(estimatedTime, currentDuration)
488
- } else {
489
- // When paused or not playing, return last known time
490
- return lastKnownTime
491
- }
492
- }
493
-
494
576
  func currentTime() -> Double? {
495
577
  return calculateCurrentTime()
496
578
  }
497
579
 
498
580
  func duration() -> Double? {
499
- return playerController.playerView?.player.duration
581
+ return playerView?.player.duration
500
582
  }
501
583
 
502
584
  func inView() -> Bool? {
503
- return playerController.playerView?.player.inView
585
+ return playerView?.player.inView
504
586
  }
505
587
 
506
588
  func mode() -> String? {
507
- return playerController.playerView?.player.mode
589
+ return playerView?.player.mode
508
590
  }
509
591
 
510
592
  func muted() -> Bool? {
511
- return playerController.playerView?.player.muted
593
+ return playerView?.player.muted
512
594
  }
513
595
 
514
596
  func phase() -> Any? {
515
- return playerController.playerView?.player.phase?.name
597
+ return playerView?.player.phase?.name
516
598
  }
517
599
 
518
600
  func playoutData() -> Any? {
519
- return playerController.playerView?.player.playoutData?.toDictionary()
601
+ return playerView?.player.playoutData?.toDictionary()
520
602
  }
521
603
 
522
604
  func projectData() -> Any? {
523
- return playerController.playerView?.player.projectData?.toDictionary()
605
+ return playerView?.player.projectData?.toDictionary()
524
606
  }
525
607
 
526
608
  func state() -> Any? {
527
- return playerController.playerView?.player.state?.name
609
+ return playerView?.player.state?.name
528
610
  }
529
611
 
530
612
  func volume() -> Float? {
531
- return playerController.playerView?.player.volume
613
+ return playerView?.player.volume
532
614
  }
533
615
 
534
616
  func autoPlayNextCancel() {
535
- playerController.playerView?.player.autoPlayNextCancel()
617
+ playerView?.player.autoPlayNextCancel()
536
618
  }
537
619
 
538
620
  func collapse() {
539
- playerController.playerView?.player.collapse()
621
+ playerView?.player.collapse()
540
622
  }
541
623
 
542
624
  func expand() {
543
- playerController.playerView?.player.expand()
625
+ playerView?.player.expand()
544
626
  }
545
627
 
546
628
  func enterFullscreen() {
@@ -552,137 +634,94 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
552
634
  }
553
635
 
554
636
  private func enterFullscreenWithLandscapeForce(forceLandscape: Bool) {
555
- // CRITICAL FIX: Set goingFullScreen flag on BBNativePlayerViewController
556
- // The SDK's BBNativePlayerViewController.supportedInterfaceOrientations only returns
557
- // .allButUpsideDown when goingFullScreen == true. Without this, the fullscreen modal
558
- // is stuck in portrait orientation.
559
- if let playerView = playerController.playerView?.player as? NSObject {
560
- // Access the private bbNativePlayerViewController using Key-Value Coding
561
- if let bbViewController = playerView.value(forKey: "bbNativePlayerViewController") as? NSObject {
637
+ // Set goingFullScreen flag on BBNativePlayerViewController for proper orientation support
638
+ if let player = playerView?.player as? NSObject {
639
+ if let bbViewController = player.value(forKey: "bbNativePlayerViewController") as? NSObject {
562
640
  bbViewController.setValue(true, forKey: "goingFullScreen")
563
- log("Set goingFullScreen = true on BBNativePlayerViewController before enterFullScreen", level: .info)
564
- } else {
565
- log("WARNING: Could not access bbNativePlayerViewController to set goingFullScreen flag", level: .warning)
641
+ log("Set goingFullScreen = true on BBNativePlayerViewController", level: .info)
566
642
  }
567
643
  }
568
644
 
569
- // iOS SDK Note: The iOS SDK uses enterFullScreen() method
570
- playerController.playerView?.player.enterFullScreen()
645
+ playerView?.player.enterFullScreen()
571
646
 
572
- // For landscape mode, force rotation after fullscreen is presented
573
647
  if forceLandscape {
574
- // Use a small delay to ensure fullscreen modal is presented before rotating
575
648
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
576
649
  if #available(iOS 16.0, *) {
577
650
  if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
578
651
  windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscape))
579
652
  log("Requested landscape rotation", level: .info)
580
-
581
- // Also update the supported orientations for the fullscreen view controller
582
- if let playerView = self?.playerController.playerView?.player as? NSObject {
583
- if let bbViewController = playerView.value(forKey: "bbNativePlayerViewController") as? NSObject {
584
- if let avPlayerVC = bbViewController.value(forKey: "avPlayerViewController") as? UIViewController {
585
- avPlayerVC.setNeedsUpdateOfSupportedInterfaceOrientations()
586
- log("Updated supported orientations for AVPlayerViewController", level: .info)
587
- }
588
- }
589
- }
590
653
  }
591
- } else {
592
- log("WARNING: requestGeometryUpdate requires iOS 16+, landscape rotation not available", level: .warning)
593
654
  }
594
655
  }
595
656
  }
596
657
  }
597
658
 
598
659
  func exitFullscreen() {
599
- // Force rotation back to portrait before exiting fullscreen
600
- if #available(iOS 16.0, *) {
601
- if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
602
- windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
603
- log("Requested portrait rotation before exitFullscreen", level: .info)
604
- }
605
- }
606
-
607
- // iOS SDK Note: The iOS SDK uses exitFullScreen() method
608
- playerController.playerView?.player.exitFullScreen()
660
+ playerView?.player.exitFullScreen()
609
661
  }
610
662
 
611
663
  func destroy() {
612
- // iOS SDK Note: The iOS SDK doesn't have a destroy() method
613
- // The player is automatically cleaned up when the view is removed
664
+ // iOS SDK cleans up automatically when view is removed
614
665
  }
615
666
 
616
667
  func pause() {
617
- playerController.playerView?.player.pause()
668
+ playerView?.player.pause()
618
669
  }
619
670
 
620
671
  func play() {
621
- playerController.playerView?.player.play()
672
+ playerView?.player.play()
622
673
  }
623
674
 
624
675
  func seek(_ offsetInSeconds: Int) {
625
- playerController.playerView?.player.seek(offsetInSeconds: offsetInSeconds as NSNumber)
676
+ playerView?.player.seek(offsetInSeconds: offsetInSeconds as NSNumber)
626
677
  }
627
678
 
628
679
  func seekRelative(_ offsetInSeconds: Double) {
629
- // Use the shared time calculation helper
630
680
  let currentTime = calculateCurrentTime()
631
-
632
- // Calculate new position and clamp to valid range [0, duration]
633
681
  let newPosition = max(0, min(currentDuration, currentTime + offsetInSeconds))
634
-
635
- // Seek to the new position using the standard seek method
636
- playerController.playerView?.player.seek(offsetInSeconds: newPosition as NSNumber)
682
+ playerView?.player.seek(offsetInSeconds: newPosition as NSNumber)
637
683
  }
638
684
 
639
685
  func setMuted(_ muted: Bool) {
640
- // Call setApiProperty directly to match setVolume pattern
641
- playerController.playerView?.setApiProperty(property: .muted, value: muted)
686
+ playerView?.setApiProperty(property: .muted, value: muted)
642
687
  }
643
688
 
644
689
  func setVolume(_ volume: Double) {
645
- // Call setApiProperty directly with Float to avoid Double->Float cast crash in SDK
646
- playerController.playerView?.setApiProperty(property: .volume, value: Float(volume))
690
+ playerView?.setApiProperty(property: .volume, value: Float(volume))
647
691
  }
648
692
 
649
693
  func loadWithClipId(_ clipId: String, initiator: String?, autoPlay: Bool?, seekTo: Double?) {
650
- playerController.playerView?.player.loadWithClipId(clipId: clipId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
694
+ playerView?.player.loadWithClipId(clipId: clipId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
651
695
  }
652
696
 
653
697
  func loadWithClipListId(_ clipListId: String, initiator: String?, autoPlay: Bool?, seekTo: Double?) {
654
- playerController.playerView?.player.loadWithClipListId(clipListId: clipListId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
698
+ playerView?.player.loadWithClipListId(clipListId: clipListId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
655
699
  }
656
700
 
657
701
  func loadWithProjectId(_ projectId: String, initiator: String?, autoPlay: Bool?, seekTo: Double?) {
658
- playerController.playerView?.player.loadWithProjectId(projectId: projectId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
702
+ playerView?.player.loadWithProjectId(projectId: projectId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
659
703
  }
660
704
 
661
705
  func loadWithClipJson(_ clipJson: String, initiator: String?, autoPlay: Bool?, seekTo: Double?) {
662
- playerController.playerView?.player.loadWithClipJson(clipJson: clipJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
706
+ playerView?.player.loadWithClipJson(clipJson: clipJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
663
707
  }
664
708
 
665
709
  func loadWithClipListJson(_ clipListJson: String, initiator: String?, autoPlay: Bool?, seekTo: Double?) {
666
- playerController.playerView?.player.loadWithClipListJson(clipListJson: clipListJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
710
+ playerView?.player.loadWithClipListJson(clipListJson: clipListJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
667
711
  }
668
712
 
669
713
  func loadWithProjectJson(_ projectJson: String, initiator: String?, autoPlay: Bool?, seekTo: Double?) {
670
- playerController.playerView?.player.loadWithProjectJson(projectJson: projectJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
714
+ playerView?.player.loadWithProjectJson(projectJson: projectJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
671
715
  }
672
716
 
673
- // Note: loadWithShortsId is NOT supported on BBPlayerView.
674
- // For Shorts playback, use the BBShortsView component instead.
675
-
676
717
  func showCastPicker() {
677
718
  log("showCastPicker called")
678
719
 
679
- // CRITICAL: Verify Google Cast SDK is initialized before proceeding
680
720
  if !GCKCastContext.isSharedInstanceInitialized() {
681
721
  log("ERROR - showCastPicker called but Google Cast SDK not initialized yet", level: .error)
682
722
  return
683
723
  }
684
724
 
685
- // Create the button lazily if it doesn't exist yet
686
725
  if independentCastButton == nil {
687
726
  setupIndependentCastButton()
688
727
  }
@@ -692,8 +731,6 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
692
731
  return
693
732
  }
694
733
 
695
- // Trigger the independent cast button to show the cast device picker
696
- // This works with the SDK because they both use the shared GCKSessionManager
697
734
  DispatchQueue.main.async {
698
735
  log("showCastPicker triggering independent GCKUICastButton")
699
736
  castButton.sendActions(for: .touchUpInside)