@bluebillywig/react-native-bb-player 8.42.14 → 8.44.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.
@@ -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,62 @@ 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
150
+
151
+ // Register with view registry for command dispatch (supports New Architecture)
152
+ if let tag = self.reactTag {
153
+ BBPlayerViewRegistry.shared.register(self, tag: tag.intValue)
154
+ }
148
155
 
149
- // Set up lifecycle observers to pause timer when app goes to background (saves battery)
150
156
  setupAppLifecycleObservers()
151
157
 
152
158
  // Find the parent view controller from the responder chain
153
- var parentVC: UIViewController?
154
159
  var responder = self.next
155
160
  while responder != nil {
156
161
  if let viewController = responder as? UIViewController {
157
- parentVC = viewController
162
+ parentViewController = viewController
158
163
  break
159
164
  }
160
165
  responder = responder?.next
161
166
  }
162
167
 
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()
168
+ if parentViewController != nil {
169
+ log("Found parent view controller: \(type(of: parentViewController!))")
185
170
  } else {
186
171
  log("WARNING - Could not find parent view controller!", level: .warning)
187
172
  }
188
173
 
189
- } else if window == nil {
174
+ setupPlayerIfNeeded()
175
+ } else {
190
176
  log("BBPlayerView.didMoveToWindow - view removed from window, isInFullscreen: \(isInFullscreen)")
191
177
 
192
- // Stop time update timer to save CPU/battery when view is not visible
193
- // Skip this during fullscreen transitions to avoid interrupting playback
178
+ // Unregister from view registry when removed from window (unless in fullscreen)
179
+ if !isInFullscreen, let tag = self.reactTag {
180
+ BBPlayerViewRegistry.shared.unregister(tag: tag.intValue)
181
+ }
182
+
194
183
  if !isInFullscreen {
195
184
  stopTimeUpdates()
196
185
  }
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
186
  }
203
187
  }
204
188
 
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
189
  // Start periodic time updates (1x per second, only if enabled)
212
190
  private func startTimeUpdates() {
213
- // Skip if time updates are disabled or timer already running
214
191
  guard enableTimeUpdates, timeUpdateTimer == nil else { return }
215
192
 
216
193
  timeUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
217
194
  guard let self = self, self.isPlaying else { return }
218
195
 
219
- // Use CACurrentMediaTime() instead of Date() - much more efficient (no system call overhead)
220
196
  let elapsedSeconds = CACurrentMediaTime() - self.playbackStartTimestamp
221
197
  let estimatedTime = self.lastKnownTime + elapsedSeconds
222
198
  let currentTime = min(estimatedTime, self.currentDuration)
223
199
 
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
200
  if self.currentDuration > 0 && abs(currentTime - self.lastEmittedTime) >= 0.5 {
227
201
  self.lastEmittedTime = currentTime
228
202
  self.onDidTriggerTimeUpdate?([
@@ -233,34 +207,33 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
233
207
  }
234
208
  }
235
209
 
236
- // Stop periodic time updates
237
210
  private func stopTimeUpdates() {
238
211
  timeUpdateTimer?.invalidate()
239
212
  timeUpdateTimer = nil
240
213
  }
241
214
 
242
- // Clean up timers, observers, and views to prevent memory leaks
243
215
  deinit {
216
+ // Unregister from view registry
217
+ if let tag = self.reactTag {
218
+ BBPlayerViewRegistry.shared.unregister(tag: tag.intValue)
219
+ }
244
220
  stopTimeUpdates()
245
221
  removeAppLifecycleObservers()
246
222
  independentCastButton?.removeFromSuperview()
247
223
  independentCastButton = nil
248
224
  }
249
225
 
250
- // MARK: - App Lifecycle Management (Battery Optimization)
226
+ // MARK: - App Lifecycle Management
251
227
 
252
228
  private func setupAppLifecycleObservers() {
253
- // Only set up once
254
229
  guard backgroundObserver == nil else { return }
255
230
 
256
- // Pause timer when app goes to background to save battery
257
231
  backgroundObserver = NotificationCenter.default.addObserver(
258
232
  forName: UIApplication.didEnterBackgroundNotification,
259
233
  object: nil,
260
234
  queue: .main
261
235
  ) { [weak self] _ in
262
236
  guard let self = self else { return }
263
- // Save current time before pausing timer so we can resume accurately
264
237
  if self.isPlaying {
265
238
  self.lastKnownTime = self.calculateCurrentTime()
266
239
  }
@@ -268,7 +241,6 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
268
241
  log("Timer paused - app entered background", level: .debug)
269
242
  }
270
243
 
271
- // Resume timer when app comes back to foreground
272
244
  foregroundObserver = NotificationCenter.default.addObserver(
273
245
  forName: UIApplication.willEnterForegroundNotification,
274
246
  object: nil,
@@ -276,7 +248,6 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
276
248
  ) { [weak self] _ in
277
249
  guard let self = self else { return }
278
250
  if self.isPlaying && self.enableTimeUpdates {
279
- // Reset timestamp to now for accurate time calculation after background
280
251
  self.playbackStartTimestamp = CACurrentMediaTime()
281
252
  self.startTimeUpdates()
282
253
  log("Timer resumed - app entered foreground", level: .debug)
@@ -295,300 +266,377 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
295
266
  }
296
267
  }
297
268
 
298
- func bbPlayerViewController(
299
- _ controller: BBPlayerViewController, didTriggerEvent event: BBPlayerEvent
300
- ) {
301
- switch event {
302
- case .requestCollapse:
303
- onDidRequestCollapse?([:])
269
+ // MARK: - Player Setup (Simplified - no intermediate view controller)
270
+
271
+ func setupPlayer() {
272
+ guard let parentVC = parentViewController else {
273
+ log("ERROR - Cannot setup player without parent view controller", level: .error)
274
+ return
275
+ }
304
276
 
305
- case .requestExpand:
306
- onDidRequestExpand?([:])
277
+ log("BBPlayerView.setupPlayer() - creating player with jsonUrl: \(jsonUrl)")
307
278
 
308
- case .failWithError(let error):
309
- onDidFailWithError?(["payload": error as Any])
279
+ // Remove any existing player view
280
+ playerView?.removeFromSuperview()
310
281
 
311
- case .requestOpenUrl(let url):
312
- onDidRequestOpenUrl?(["payload": url as Any])
282
+ // Convert options dictionary
283
+ var optionsDict: [String: Any] = [:]
284
+ if let opts = options as? [String: Any] {
285
+ optionsDict = opts
286
+ }
313
287
 
314
- case .setupWithJsonUrl(let url):
315
- onDidSetupWithJsonUrl?(["payload": url as Any])
288
+ // Create player view directly using SDK factory method
289
+ // Pass the parent view controller so SDK can present fullscreen modals
290
+ playerView = BBNativePlayer.createPlayerView(
291
+ uiViewController: parentVC,
292
+ frame: bounds,
293
+ jsonUrl: jsonUrl,
294
+ options: optionsDict
295
+ )
296
+
297
+ if let pv = playerView {
298
+ // Add player view directly to BBPlayerView (no intermediate view controller)
299
+ addSubview(pv)
300
+
301
+ pv.translatesAutoresizingMaskIntoConstraints = false
302
+ NSLayoutConstraint.activate([
303
+ pv.topAnchor.constraint(equalTo: topAnchor),
304
+ pv.leadingAnchor.constraint(equalTo: leadingAnchor),
305
+ pv.trailingAnchor.constraint(equalTo: trailingAnchor),
306
+ pv.bottomAnchor.constraint(equalTo: bottomAnchor)
307
+ ])
316
308
 
317
- case .adError(let error):
318
- onDidTriggerAdError?(["payload": error as Any])
309
+ // Set ourselves as the delegate directly
310
+ pv.delegate = self
319
311
 
320
- case .adFinished:
321
- onDidTriggerAdFinished?([:])
312
+ log("Player view created and added directly to BBPlayerView")
313
+ } else {
314
+ log("ERROR - playerView is nil after createPlayerView!", level: .error)
315
+ }
316
+ }
322
317
 
323
- case .adLoaded:
324
- onDidTriggerAdLoaded?([:])
318
+ // MARK: - BBNativePlayerViewDelegate Implementation
325
319
 
326
- case .adLoadStart:
327
- onDidTriggerAdLoadStart?([:])
320
+ func bbNativePlayerView(didRequestCollapse playerView: BBNativePlayerView) {
321
+ onDidRequestCollapse?([:])
322
+ }
328
323
 
329
- case .adNotFound:
330
- onDidTriggerAdNotFound?([:])
324
+ func bbNativePlayerView(didRequestExpand playerView: BBNativePlayerView) {
325
+ onDidRequestExpand?([:])
326
+ }
331
327
 
332
- case .adQuartile1:
333
- onDidTriggerAdQuartile1?([:])
328
+ func bbNativePlayerView(playerView: BBNativePlayerView, didFailWithError error: String?) {
329
+ onDidFailWithError?(["payload": error as Any])
330
+ }
334
331
 
335
- case .adQuartile2:
336
- onDidTriggerAdQuartile2?([:])
332
+ func didRequestOpenUrl(url: String?) {
333
+ onDidRequestOpenUrl?(["payload": url as Any])
334
+ }
337
335
 
338
- case .adQuartile3:
339
- onDidTriggerAdQuartile3?([:])
336
+ func didSetupWithJsonUrl(url: String?) {
337
+ onDidSetupWithJsonUrl?(["payload": url as Any])
338
+ }
340
339
 
341
- case .adStarted:
342
- onDidTriggerAdStarted?([:])
340
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerAdError error: String?) {
341
+ onDidTriggerAdError?(["payload": error as Any])
342
+ }
343
343
 
344
- case .allAdsCompleted:
345
- onDidTriggerAllAdsCompleted?([:])
344
+ func bbNativePlayerView(didTriggerAdFinished playerView: BBNativePlayerView) {
345
+ onDidTriggerAdFinished?([:])
346
+ }
346
347
 
347
- case .autoPause(let why):
348
- onDidTriggerAutoPause?(["why": why as Any])
348
+ func bbNativePlayerView(didTriggerAdLoadStart playerView: BBNativePlayerView) {
349
+ onDidTriggerAdLoadStart?([:])
350
+ }
349
351
 
350
- case .autoPausePlay(let why):
351
- onDidTriggerAutoPausePlay?(["why": why as Any])
352
+ func bbNativePlayerView(didTriggerAdLoaded playerView: BBNativePlayerView) {
353
+ onDidTriggerAdLoaded?([:])
354
+ }
352
355
 
353
- case .canPlay:
354
- onDidTriggerCanPlay?([:])
356
+ func bbNativePlayerView(didTriggerAdNotFound playerView: BBNativePlayerView) {
357
+ onDidTriggerAdNotFound?([:])
358
+ }
355
359
 
356
- case .customStatistics(let ident, let ev, let aux):
357
- onDidTriggerCustomStatistics?([
358
- "ident": ident,
359
- "ev": ev,
360
- "aux": aux,
361
- ])
360
+ func bbNativePlayerView(didTriggerAdQuartile1 playerView: BBNativePlayerView) {
361
+ onDidTriggerAdQuartile1?([:])
362
+ }
362
363
 
363
- case .durationChange(let duration):
364
- currentDuration = duration
365
- onDidTriggerDurationChange?(["duration": duration as Any])
364
+ func bbNativePlayerView(didTriggerAdQuartile2 playerView: BBNativePlayerView) {
365
+ onDidTriggerAdQuartile2?([:])
366
+ }
366
367
 
367
- case .ended:
368
- isPlaying = false
369
- stopTimeUpdates()
370
- onDidTriggerEnded?([:])
368
+ func bbNativePlayerView(didTriggerAdQuartile3 playerView: BBNativePlayerView) {
369
+ onDidTriggerAdQuartile3?([:])
370
+ }
371
371
 
372
- case .fullscreen:
373
- isInFullscreen = true
374
- onDidTriggerFullscreen?([:])
372
+ func bbNativePlayerView(didTriggerAdStarted playerView: BBNativePlayerView) {
373
+ onDidTriggerAdStarted?([:])
374
+ }
375
375
 
376
- case .mediaClipFailed:
377
- onDidTriggerMediaClipFailed?([:])
376
+ func bbNativePlayerView(didTriggerAllAdsCompleted playerView: BBNativePlayerView) {
377
+ onDidTriggerAllAdsCompleted?([:])
378
+ }
378
379
 
379
- case .mediaClipLoaded(let clipData):
380
- onDidTriggerMediaClipLoaded?(clipData.toDictionary() as [String: Any])
380
+ func bbNativePlayerView(didTriggerAutoPause playerView: BBNativePlayerView) {
381
+ onDidTriggerAutoPause?([:])
382
+ }
381
383
 
382
- case .modeChange(let mode):
383
- onDidTriggerModeChange?(["mode": mode as Any])
384
+ func bbNativePlayerView(didTriggerAutoPausePlay playerView: BBNativePlayerView) {
385
+ onDidTriggerAutoPausePlay?([:])
386
+ }
384
387
 
385
- case .pause:
386
- isPlaying = false
387
- stopTimeUpdates()
388
- onDidTriggerPause?([:])
388
+ func bbNativePlayerView(didTriggerCanPlay playerView: BBNativePlayerView) {
389
+ onDidTriggerCanPlay?([:])
390
+ }
389
391
 
390
- case .phaseChange(let phase):
391
- onDidTriggerPhaseChange?(["phase": (phase?.name ?? nil) as Any])
392
+ func bbNativePlayerView(
393
+ didTriggerCustomStatistics playerView: BBNativePlayerView, ident: String, ev: String,
394
+ aux: [String: String]
395
+ ) {
396
+ onDidTriggerCustomStatistics?([
397
+ "ident": ident,
398
+ "ev": ev,
399
+ "aux": aux,
400
+ ])
401
+ }
392
402
 
393
- case .play:
394
- onDidTriggerPlay?([:])
403
+ func bbNativePlayerView(
404
+ playerView: BBNativePlayerView, didTriggerDurationChange duration: Double
405
+ ) {
406
+ currentDuration = duration
407
+ onDidTriggerDurationChange?(["duration": duration as Any])
408
+ }
395
409
 
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?([:])
410
+ func bbNativePlayerView(didTriggerEnded playerView: BBNativePlayerView) {
411
+ isPlaying = false
412
+ stopTimeUpdates()
413
+ onDidTriggerEnded?([:])
414
+ }
403
415
 
404
- case .projectLoaded(let projectData):
405
- onDidTriggerProjectLoaded?(projectData.toDictionary() as [String: Any])
416
+ func bbNativePlayerView(didTriggerFullscreen playerView: BBNativePlayerView) {
417
+ isInFullscreen = true
418
+ log("FULLSCREEN ENTRY")
406
419
 
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?([:])
420
+ // Enable landscape orientation for fullscreen
421
+ if let orientationLockClass = NSClassFromString("OrientationLock") as? NSObject.Type {
422
+ orientationLockClass.setValue(true, forKey: "isFullscreen")
423
+ }
412
424
 
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])
425
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
426
+ guard let self = self, let parentVC = self.parentViewController else { return }
427
+ if let presentedVC = parentVC.presentedViewController {
428
+ if #available(iOS 16.0, *) {
429
+ presentedVC.setNeedsUpdateOfSupportedInterfaceOrientations()
430
+ }
431
+ UIViewController.attemptRotationToDeviceOrientation()
432
+ }
433
+ }
419
434
 
420
- case .seeking:
421
- onDidTriggerSeeking?([:])
435
+ onDidTriggerFullscreen?([:])
436
+ }
422
437
 
423
- case .stall:
424
- onDidTriggerStall?([:])
438
+ func bbNativePlayerView(didTriggerMediaClipFailed playerView: BBNativePlayerView) {
439
+ onDidTriggerMediaClipFailed?([:])
440
+ }
425
441
 
426
- case .stateChange(let state):
427
- onDidTriggerStateChange?(["state": (state?.name ?? nil) as Any])
442
+ func bbNativePlayerView(
443
+ playerView: BBNativePlayerView, didTriggerMediaClipLoaded data: MediaClip
444
+ ) {
445
+ onDidTriggerMediaClipLoaded?(data.toDictionary() as [String: Any])
446
+ }
428
447
 
429
- case .viewFinished:
430
- onDidTriggerViewFinished?([:])
448
+ func bbNativePlayerView(didTriggerModeChange playerView: BBNativePlayerView, mode: String?) {
449
+ onDidTriggerModeChange?(["mode": mode as Any])
450
+ }
431
451
 
432
- case .viewStarted:
433
- onDidTriggerViewStarted?([:])
452
+ func bbNativePlayerView(didTriggerPause playerView: BBNativePlayerView) {
453
+ isPlaying = false
454
+ stopTimeUpdates()
455
+ onDidTriggerPause?([:])
456
+ }
434
457
 
435
- case .volumeChange(let volume):
436
- onDidTriggerVolumeChange?([
437
- "volume": volume,
438
- "muted": (volume == 0.0)
439
- ])
458
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerPhaseChange phase: Phase?) {
459
+ onDidTriggerPhaseChange?(["phase": (phase?.name ?? nil) as Any])
460
+ }
440
461
 
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
- }
462
+ func bbNativePlayerView(didTriggerPlay playerView: BBNativePlayerView) {
463
+ onDidTriggerPlay?([:])
446
464
  }
447
465
 
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
- }
466
+ func bbNativePlayerView(didTriggerPlaying playerView: BBNativePlayerView) {
467
+ isPlaying = true
468
+ playbackStartTimestamp = CACurrentMediaTime()
469
+ lastEmittedTime = 0.0
470
+ lastKnownTime = calculateCurrentTime()
471
+ startTimeUpdates()
472
+ onDidTriggerPlaying?([:])
473
+ }
455
474
 
456
- log("Creating independent GCKUICastButton (SDK confirmed initialized)")
475
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerProjectLoaded data: Project) {
476
+ onDidTriggerProjectLoaded?(data.toDictionary() as [String: Any])
477
+ }
457
478
 
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))
479
+ func bbNativePlayerView(didTriggerRetractFullscreen playerView: BBNativePlayerView) {
480
+ isInFullscreen = false
481
+ log("FULLSCREEN EXIT")
463
482
 
464
- guard let castButton = independentCastButton else {
465
- log("ERROR - Failed to create independent cast button", level: .error)
466
- return
483
+ // Disable landscape orientation
484
+ if let orientationLockClass = NSClassFromString("OrientationLock") as? NSObject.Type {
485
+ orientationLockClass.setValue(false, forKey: "isFullscreen")
467
486
  }
468
487
 
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)
488
+ // Force rotation back to portrait
489
+ if #available(iOS 16.0, *) {
490
+ if let windowScene = window?.windowScene {
491
+ windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
492
+ }
493
+ } else {
494
+ UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
495
+ }
474
496
 
475
- log("Successfully created and added independent GCKUICastButton to view hierarchy")
476
- }
497
+ UIViewController.attemptRotationToDeviceOrientation()
477
498
 
478
- // Safe handler for cast button taps - uses an independent GCKUICastButton
479
- @objc private func handleCastButtonTap() {
480
- log("Cast button tapped")
499
+ onDidTriggerRetractFullscreen?([:])
500
+ }
481
501
 
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
- }
502
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerSeeked seekOffset: Double) {
503
+ lastKnownTime = seekOffset
504
+ playbackStartTimestamp = CACurrentMediaTime()
505
+ lastEmittedTime = 0.0
506
+ onDidTriggerSeeked?(["payload": seekOffset as Any])
507
+ }
487
508
 
488
- // Create the button lazily on first use
489
- if independentCastButton == nil {
490
- setupIndependentCastButton()
491
- }
509
+ func bbNativePlayerView(didTriggerSeeking playerView: BBNativePlayerView) {
510
+ onDidTriggerSeeking?([:])
511
+ }
492
512
 
493
- guard let castButton = independentCastButton else {
494
- log("ERROR - Failed to create independent cast button", level: .error)
495
- return
496
- }
513
+ func bbNativePlayerView(didTriggerStall playerView: BBNativePlayerView) {
514
+ onDidTriggerStall?([:])
515
+ }
497
516
 
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)
517
+ func bbNativePlayerView(playerView: BBNativePlayerView, didTriggerStateChange state: State?) {
518
+ onDidTriggerStateChange?(["state": (state?.name ?? nil) as Any])
502
519
  }
503
520
 
504
- func setupPlayer() {
505
- log("BBPlayerView.setupPlayer() called with jsonUrl: \(playerController.jsonUrl)")
506
- playerController.setupPlayer()
507
- log("BBPlayerView.setupPlayer() completed")
521
+ func bbNativePlayerView(didTriggerViewFinished playerView: BBNativePlayerView) {
522
+ onDidTriggerViewFinished?([:])
508
523
  }
509
524
 
510
- func adMediaHeight() -> Int? {
511
- return playerController.playerView?.player.adMediaHeight
525
+ func bbNativePlayerView(didTriggerViewStarted playerView: BBNativePlayerView) {
526
+ onDidTriggerViewStarted?([:])
512
527
  }
513
528
 
514
- func adMediaWidth() -> Int? {
515
- return playerController.playerView?.player.adMediaWidth
529
+ func bbNativePlayerView(didTriggerVolumeChange playerView: BBNativePlayerView, volume: Double) {
530
+ onDidTriggerVolumeChange?([
531
+ "volume": volume,
532
+ "muted": (volume == 0.0)
533
+ ])
516
534
  }
517
535
 
518
- func adMediaClip() -> Any? {
519
- return playerController.playerView?.player.clipData?.toDictionary()
536
+ func bbNativePlayerView(didTriggerApiReady playerView: BBNativePlayerView) {
537
+ onDidTriggerApiReady?([:])
520
538
  }
521
539
 
522
- func controls() -> Bool? {
523
- return nil
540
+ // MARK: - Cast Button Setup
541
+
542
+ private func setupIndependentCastButton() {
543
+ if !GCKCastContext.isSharedInstanceInitialized() {
544
+ log("ERROR - Cannot create cast button: Google Cast SDK not initialized yet", level: .error)
545
+ return
546
+ }
547
+
548
+ independentCastButton = GCKUICastButton(frame: CGRect(x: -1000, y: -1000, width: 1, height: 1))
549
+
550
+ guard let castButton = independentCastButton else {
551
+ log("ERROR - Failed to create independent cast button", level: .error)
552
+ return
553
+ }
554
+
555
+ castButton.alpha = 0.0
556
+ castButton.isUserInteractionEnabled = false
557
+ addSubview(castButton)
524
558
  }
525
559
 
526
560
  // MARK: - Private Helper Methods
527
561
 
528
- /// Calculate estimated current time based on playback state
529
- /// iOS SDK doesn't expose direct currentTime property, so we estimate it
530
562
  private func calculateCurrentTime() -> Double {
531
563
  if isPlaying && playbackStartTimestamp > 0 {
532
- // Use CACurrentMediaTime() - more efficient than Date() (no system call overhead)
533
564
  let elapsedSeconds = CACurrentMediaTime() - playbackStartTimestamp
534
565
  let estimatedTime = lastKnownTime + elapsedSeconds
535
566
  return min(estimatedTime, currentDuration)
536
567
  } else {
537
- // When paused or not playing, return last known time
538
568
  return lastKnownTime
539
569
  }
540
570
  }
541
571
 
572
+ // MARK: - Public API Methods
573
+
574
+ func adMediaHeight() -> Int? {
575
+ return playerView?.player.adMediaHeight
576
+ }
577
+
578
+ func adMediaWidth() -> Int? {
579
+ return playerView?.player.adMediaWidth
580
+ }
581
+
582
+ func clipData() -> Any? {
583
+ return playerView?.player.clipData?.toDictionary()
584
+ }
585
+
586
+ func controls() -> Bool? {
587
+ return nil
588
+ }
589
+
542
590
  func currentTime() -> Double? {
543
591
  return calculateCurrentTime()
544
592
  }
545
593
 
546
594
  func duration() -> Double? {
547
- return playerController.playerView?.player.duration
595
+ return playerView?.player.duration
548
596
  }
549
597
 
550
598
  func inView() -> Bool? {
551
- return playerController.playerView?.player.inView
599
+ return playerView?.player.inView
552
600
  }
553
601
 
554
602
  func mode() -> String? {
555
- return playerController.playerView?.player.mode
603
+ return playerView?.player.mode
556
604
  }
557
605
 
558
606
  func muted() -> Bool? {
559
- return playerController.playerView?.player.muted
607
+ return playerView?.player.muted
560
608
  }
561
609
 
562
610
  func phase() -> Any? {
563
- return playerController.playerView?.player.phase?.name
611
+ return playerView?.player.phase?.name
564
612
  }
565
613
 
566
614
  func playoutData() -> Any? {
567
- return playerController.playerView?.player.playoutData?.toDictionary()
615
+ return playerView?.player.playoutData?.toDictionary()
568
616
  }
569
617
 
570
618
  func projectData() -> Any? {
571
- return playerController.playerView?.player.projectData?.toDictionary()
619
+ return playerView?.player.projectData?.toDictionary()
572
620
  }
573
621
 
574
622
  func state() -> Any? {
575
- return playerController.playerView?.player.state?.name
623
+ return playerView?.player.state?.name
576
624
  }
577
625
 
578
626
  func volume() -> Float? {
579
- return playerController.playerView?.player.volume
627
+ return playerView?.player.volume
580
628
  }
581
629
 
582
630
  func autoPlayNextCancel() {
583
- playerController.playerView?.player.autoPlayNextCancel()
631
+ playerView?.player.autoPlayNextCancel()
584
632
  }
585
633
 
586
634
  func collapse() {
587
- playerController.playerView?.player.collapse()
635
+ playerView?.player.collapse()
588
636
  }
589
637
 
590
638
  func expand() {
591
- playerController.playerView?.player.expand()
639
+ playerView?.player.expand()
592
640
  }
593
641
 
594
642
  func enterFullscreen() {
@@ -600,131 +648,188 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
600
648
  }
601
649
 
602
650
  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 {
651
+ // Set goingFullScreen flag on BBNativePlayerViewController for proper orientation support
652
+ if let player = playerView?.player as? NSObject {
653
+ if let bbViewController = player.value(forKey: "bbNativePlayerViewController") as? NSObject {
610
654
  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)
655
+ log("Set goingFullScreen = true on BBNativePlayerViewController", level: .info)
614
656
  }
615
657
  }
616
658
 
617
- // iOS SDK Note: The iOS SDK uses enterFullScreen() method
618
- playerController.playerView?.player.enterFullScreen()
659
+ playerView?.player.enterFullScreen()
619
660
 
620
- // For landscape mode, force rotation after fullscreen is presented
621
661
  if forceLandscape {
622
- // Use a small delay to ensure fullscreen modal is presented before rotating
623
662
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
624
663
  if #available(iOS 16.0, *) {
625
664
  if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
626
665
  windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscape))
627
666
  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
667
  }
639
- } else {
640
- log("WARNING: requestGeometryUpdate requires iOS 16+, landscape rotation not available", level: .warning)
641
668
  }
642
669
  }
643
670
  }
644
671
  }
645
672
 
646
673
  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()
674
+ playerView?.player.exitFullScreen()
651
675
  }
652
676
 
653
677
  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
678
+ // iOS SDK cleans up automatically when view is removed
656
679
  }
657
680
 
658
681
  func pause() {
659
- playerController.playerView?.player.pause()
682
+ playerView?.player.pause()
660
683
  }
661
684
 
662
685
  func play() {
663
- playerController.playerView?.player.play()
686
+ playerView?.player.play()
664
687
  }
665
688
 
666
689
  func seek(_ offsetInSeconds: Int) {
667
- playerController.playerView?.player.seek(offsetInSeconds: offsetInSeconds as NSNumber)
690
+ playerView?.player.seek(offsetInSeconds: offsetInSeconds as NSNumber)
668
691
  }
669
692
 
670
693
  func seekRelative(_ offsetInSeconds: Double) {
671
- // Use the shared time calculation helper
672
694
  let currentTime = calculateCurrentTime()
673
-
674
- // Calculate new position and clamp to valid range [0, duration]
675
695
  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)
696
+ playerView?.player.seek(offsetInSeconds: newPosition as NSNumber)
679
697
  }
680
698
 
681
699
  func setMuted(_ muted: Bool) {
682
- // Call setApiProperty directly to match setVolume pattern
683
- playerController.playerView?.setApiProperty(property: .muted, value: muted)
700
+ playerView?.setApiProperty(property: .muted, value: muted)
684
701
  }
685
702
 
686
703
  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))
704
+ playerView?.setApiProperty(property: .volume, value: Float(volume))
689
705
  }
690
706
 
691
707
  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?)
708
+ playerView?.player.loadWithClipId(clipId: clipId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
693
709
  }
694
710
 
695
711
  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?)
712
+ playerView?.player.loadWithClipListId(clipListId: clipListId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
697
713
  }
698
714
 
699
715
  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?)
716
+ playerView?.player.loadWithProjectId(projectId: projectId, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
701
717
  }
702
718
 
703
719
  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?)
720
+ playerView?.player.loadWithClipJson(clipJson: clipJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
705
721
  }
706
722
 
707
723
  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?)
724
+ playerView?.player.loadWithClipListJson(clipListJson: clipListJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
709
725
  }
710
726
 
711
727
  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?)
713
- }
728
+ playerView?.player.loadWithProjectJson(projectJson: projectJson, initiator: initiator, autoPlay: autoPlay, seekTo: seekTo as NSNumber?)
729
+ }
730
+
731
+ /**
732
+ * Load content from a JSON URL into the existing player.
733
+ * Extracts IDs from the URL and uses the native SDK's loadWithXxxId methods.
734
+ * This is more reliable than parsing JSON because the SDK handles all the loading internally.
735
+ *
736
+ * Note: Shorts URLs (/sh/{id}.json) are NOT supported here - use BBShortsView instead.
737
+ */
738
+ func loadWithJsonUrl(_ url: String, autoPlay: Bool) {
739
+ NSLog("BBPlayerView.loadWithJsonUrl called - url: %@, autoPlay: %d", url, autoPlay)
740
+ guard playerView != nil else {
741
+ NSLog("BBPlayerView.loadWithJsonUrl ERROR - playerView not initialized")
742
+ return
743
+ }
744
+
745
+ NSLog("BBPlayerView.loadWithJsonUrl - playerView exists, parsing URL")
746
+
747
+ // Extract ID from URL patterns like:
748
+ // /c/{id}.json or /mediaclip/{id}.json -> clip ID
749
+ // /l/{id}.json or /mediacliplist/{id}.json -> clip list ID
750
+ // /pj/{id}.json or /project/{id}.json -> project ID
751
+
752
+ let clipIdPattern = "/c/([0-9]+)\\.json|/mediaclip/([0-9]+)"
753
+ let clipListIdPattern = "/l/([0-9]+)\\.json|/mediacliplist/([0-9]+)"
754
+ let projectIdPattern = "/pj/([0-9]+)\\.json|/project/([0-9]+)"
755
+ let shortsIdPattern = "/sh/([0-9]+)\\.json"
756
+
757
+ if let shortsMatch = url.range(of: shortsIdPattern, options: .regularExpression) {
758
+ // Shorts require a separate BBShortsView component
759
+ log("ERROR - Shorts URLs are not supported in BBPlayerView. Use BBShortsView instead.", level: .error)
760
+ onDidFailWithError?(["payload": "Shorts URLs are not supported in BBPlayerView. Use BBShortsView instead."])
761
+ return
762
+ }
763
+
764
+ if let match = url.range(of: clipListIdPattern, options: .regularExpression) {
765
+ // Extract the cliplist ID
766
+ if let clipListId = extractIdFromUrl(url, pattern: clipListIdPattern) {
767
+ NSLog("BBPlayerView.loadWithJsonUrl - Loading ClipList by ID: %@", clipListId)
768
+ playerView?.player.loadWithClipListId(clipListId: clipListId, initiator: "external", autoPlay: autoPlay, seekTo: nil)
769
+ } else {
770
+ NSLog("BBPlayerView.loadWithJsonUrl ERROR - Failed to extract cliplist ID from URL: %@", url)
771
+ }
772
+ return
773
+ }
714
774
 
715
- // Note: loadWithShortsId is NOT supported on BBPlayerView.
716
- // For Shorts playback, use the BBShortsView component instead.
775
+ if let match = url.range(of: projectIdPattern, options: .regularExpression) {
776
+ // Extract the project ID
777
+ if let projectId = extractIdFromUrl(url, pattern: projectIdPattern) {
778
+ NSLog("BBPlayerView.loadWithJsonUrl - Loading Project by ID: %@", projectId)
779
+ playerView?.player.loadWithProjectId(projectId: projectId, initiator: "external", autoPlay: autoPlay, seekTo: nil)
780
+ } else {
781
+ NSLog("BBPlayerView.loadWithJsonUrl ERROR - Failed to extract project ID from URL: %@", url)
782
+ }
783
+ return
784
+ }
785
+
786
+ if let match = url.range(of: clipIdPattern, options: .regularExpression) {
787
+ // Extract the clip ID
788
+ if let clipId = extractIdFromUrl(url, pattern: clipIdPattern) {
789
+ NSLog("BBPlayerView.loadWithJsonUrl - Loading Clip by ID: %@", clipId)
790
+ playerView?.player.loadWithClipId(clipId: clipId, initiator: "external", autoPlay: autoPlay, seekTo: nil)
791
+ } else {
792
+ NSLog("BBPlayerView.loadWithJsonUrl ERROR - Failed to extract clip ID from URL: %@", url)
793
+ }
794
+ return
795
+ }
796
+
797
+ NSLog("BBPlayerView.loadWithJsonUrl ERROR - Unknown URL format, cannot extract ID: %@", url)
798
+ onDidFailWithError?(["payload": "Cannot load content: unsupported URL format"])
799
+ }
800
+
801
+ /**
802
+ * Helper to extract numeric ID from URL using regex pattern with capture groups
803
+ */
804
+ private func extractIdFromUrl(_ url: String, pattern: String) -> String? {
805
+ do {
806
+ let regex = try NSRegularExpression(pattern: pattern, options: [])
807
+ let range = NSRange(url.startIndex..., in: url)
808
+ if let match = regex.firstMatch(in: url, options: [], range: range) {
809
+ // Try each capture group (pattern has multiple alternatives with |)
810
+ for i in 1..<match.numberOfRanges {
811
+ if let groupRange = Range(match.range(at: i), in: url) {
812
+ let extracted = String(url[groupRange])
813
+ if !extracted.isEmpty {
814
+ return extracted
815
+ }
816
+ }
817
+ }
818
+ }
819
+ } catch {
820
+ log("ERROR - Regex error: \(error)", level: .error)
821
+ }
822
+ return nil
823
+ }
717
824
 
718
825
  func showCastPicker() {
719
826
  log("showCastPicker called")
720
827
 
721
- // CRITICAL: Verify Google Cast SDK is initialized before proceeding
722
828
  if !GCKCastContext.isSharedInstanceInitialized() {
723
829
  log("ERROR - showCastPicker called but Google Cast SDK not initialized yet", level: .error)
724
830
  return
725
831
  }
726
832
 
727
- // Create the button lazily if it doesn't exist yet
728
833
  if independentCastButton == nil {
729
834
  setupIndependentCastButton()
730
835
  }
@@ -734,8 +839,6 @@ class BBPlayerView: UIView, BBPlayerViewControllerDelegate {
734
839
  return
735
840
  }
736
841
 
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
842
  DispatchQueue.main.async {
740
843
  log("showCastPicker triggering independent GCKUICastButton")
741
844
  castButton.sendActions(for: .touchUpInside)