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