From a92384dd25537cba8369478d0c9c4d48bb7a7d33 Mon Sep 17 00:00:00 2001 From: Lucas Tonussi Date: Fri, 12 Jun 2026 10:15:40 -0300 Subject: [PATCH 1/4] Enhance AdsViewController with off-screen ad handling - Introduced off-screen ad support by adding a UIWindow for off-screen ads. - Refactored ad session management to check for off-screen requests and adjust view frames accordingly. - Improved session state management by detaching from the off-screen host when necessary. - Added helper methods for off-screen ad detection and attachment to streamline the ad loading process. --- .../Sources/smads/AdsViewController.swift | 92 ++++++++++++++++--- 1 file changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift index 5768fe46..637b6b28 100644 --- a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift +++ b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift @@ -31,6 +31,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe var sentOnComplete = false var ppID: String? = nil var isRegistered = false + private var offScreenHostWindow: UIWindow? @IBOutlet weak var videoView: UIView! @IBOutlet weak var companionView: UIView! @@ -55,43 +56,105 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) print("AD: viewDidAppear at \(Date())") - if isRegistered { + beginAdSessionIfNeeded() + } + + func setup(channel: FlutterMethodChannel?, adUrl: String?, contentUrl: String?, screen: Screen, args: [String: Any]?) { + print("[PREROLL] SETUP IS CALLED") + + self.channel = channel + self.adUrl = adUrl + self.contentUrl = contentUrl + self.screen = screen + self.args = args + + resetSessionState() + scheduleBeginAdSessionIfNeeded() + } + + private func scheduleBeginAdSessionIfNeeded() { + DispatchQueue.main.async { [weak self] in + self?.beginAdSessionIfNeeded() + } + } + + private func beginAdSessionIfNeeded() { + guard !isRegistered else { return } + + loadViewIfNeeded() + + let isOffScreenAd = isOffScreenAdRequest() + let viewInWindow = viewIfLoaded?.window != nil + guard viewInWindow || isOffScreenAd else { return } + if isOffScreenAd && !viewInWindow { + attachToOffScreenWindowIfNeeded() + } + setUpContentPlayer() setUpAdsLoader() setupAudioSession() - requestAds() - if self.videoView.frame.width > self.view.frame.width { - self.videoView.frame = CGRect( + if let videoView = videoView, videoView.frame.width > view.frame.width { + videoView.frame = CGRect( x: 0, y: 0, - width: self.view.frame.width, - height: self.view.frame.width / 1.333333333 + width: view.frame.width, + height: view.frame.width / 1.333333333 ) } isRegistered = true } - func setup(channel: FlutterMethodChannel?, adUrl: String?, contentUrl: String?, screen: Screen, args: [String: Any]?) { - print("[PREROLL] SETUP IS CALLED") + private func isOffScreenAdRequest() -> Bool { + args?["__OFF_SCREEN__"] as? String == "1" + } - self.channel = channel - self.adUrl = adUrl - self.contentUrl = contentUrl - self.screen = screen - self.args = args + private func attachToOffScreenWindowIfNeeded() { + loadViewIfNeeded() + guard isOffScreenAdRequest(), view.window == nil else { return } + + detachFromOffScreenHost() + + let adFrame = CGRect(x: 0, y: 0, width: 640, height: 480) + let window: UIWindow + + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.screen == UIScreen.main } + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + if let scene = scene { + window = UIWindow(windowScene: scene) + } else { + window = UIWindow(frame: adFrame) + } + + window.frame = adFrame + window.windowLevel = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue - 1) + window.rootViewController = self + view.frame = adFrame + window.isHidden = false + offScreenHostWindow = window - resetSessionState() + } + + private func detachFromOffScreenHost() { + guard offScreenHostWindow != nil else { return } + + offScreenHostWindow?.isHidden = true + offScreenHostWindow?.rootViewController = nil + offScreenHostWindow = nil } private func resetSessionState() { loadViewIfNeeded() + detachFromOffScreenHost() + sentOnComplete = false playing = false active = true @@ -106,6 +169,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe private func cleanupPlaybackResources() { removePlaybackObservers() + detachFromOffScreenHost() adsManager?.delegate = nil adsManager?.destroy() From 6511fac659582d2dcd879c9e7d27d8af0f67155c Mon Sep 17 00:00:00 2001 From: Lucas Tonussi Date: Mon, 15 Jun 2026 22:35:50 -0300 Subject: [PATCH 2/4] Enhance AdsViewController audio session management - Updated audio session setup to support AirPlay and Bluetooth A2DP. - Added handling for audio session interruptions to manage ad playback effectively. - Adjusted background behavior for off-screen audio ads to ensure continuous playback. - Refined ad request logic to accommodate off-screen ad scenarios. --- .../Sources/smads/AdsViewController.swift | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift index 637b6b28..6dd9f9fa 100644 --- a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift +++ b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift @@ -219,12 +219,10 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe func setupAudioSession() { do { + let options: AVAudioSession.CategoryOptions = [.allowAirPlay, .allowBluetoothA2DP] + let mode: AVAudioSession.Mode = isOffScreenAdRequest() ? .default : .moviePlayback + try AVAudioSession.sharedInstance().setCategory(.playback, mode: mode, options: options) try AVAudioSession.sharedInstance().setActive(true) - if #available(iOS 10.0, *) { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback, options: []) - } else { - try AVAudioSession.sharedInstance().setCategory(.playback, options: []) - } } catch let error { print("AD: \(error.localizedDescription)") } @@ -321,6 +319,36 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe name: UIApplication.didEnterBackgroundNotification, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(AdsViewController.handleAudioSessionInterruption(notification:)), + name: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance() + ) + } + + @objc func handleAudioSessionInterruption(notification: Notification) { + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + switch type { + case .began: + print("AD: audio session interruption began") + case .ended: + print("AD: audio session interruption ended") + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + setupAudioSession() + adsManager?.resume() + } + } + @unknown default: + break + } } // Initialize ad display container. @@ -364,7 +392,10 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe @objc func applicationDidEnterBackground(notification: NSNotification) { print("AD: applicationDidEnterBackground") - self.active = false + // Off-screen audio ads must keep playing in background. + if !isOffScreenAdRequest() { + self.active = false + } } @objc func contentDidFinishPlaying(_ notification: Notification) { @@ -406,7 +437,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe } func requestAds() { - guard self.active else { + guard self.active || isOffScreenAdRequest() else { onComplete() return } From 4422e1537183c6e28c59b4941217cebd38f6596d Mon Sep 17 00:00:00 2001 From: Lucas Tonussi Date: Thu, 16 Jul 2026 15:22:18 -0300 Subject: [PATCH 3/4] Refactor AdsViewController to remove Picture-in-Picture support - Removed Picture-in-Picture related properties and logic from AdsViewController. - Simplified ad request handling by eliminating dependencies on PiP components. - Cleaned up code to enhance readability and maintainability. --- .../Sources/smads/AdsViewController.swift | 34 ++----------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift index 6dd9f9fa..4b185847 100644 --- a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift +++ b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift @@ -3,7 +3,7 @@ import GoogleInteractiveMediaAds import MediaPlayer import UIKit -class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, AVPictureInPictureControllerDelegate { +class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDelegate { var channel: FlutterMethodChannel? // Video objects @@ -16,10 +16,6 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe var adsManager: IMAAdsManager? var companionSlot: IMACompanionAdSlot? - // PiP objects. - var pictureInPictureController: AVPictureInPictureController? - var pictureInPictureProxy: IMAPictureInPictureProxy? - var isVideo: Bool = true var adUrl: String! var contentUrl: String! @@ -35,7 +31,6 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe @IBOutlet weak var videoView: UIView! @IBOutlet weak var companionView: UIView! - @IBOutlet weak var pictureInPictureButton: UIButton! override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) @@ -184,10 +179,6 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe contentPlayer = nil contentPlayhead = nil - pictureInPictureController?.delegate = nil - pictureInPictureController = nil - pictureInPictureProxy = nil - contentPlayerLayer?.removeFromSuperlayer() contentPlayerLayer = nil @@ -280,23 +271,6 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe // Set up our content playhead and contentComplete callback. contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: contentPlayer) - // Set ourselves up for PiP. - pictureInPictureProxy = IMAPictureInPictureProxy(avPictureInPictureControllerDelegate: self) - - if let contentPlayerLayer { - pictureInPictureController = AVPictureInPictureController(playerLayer: contentPlayerLayer) - } - - if pictureInPictureController != nil { - if #available(iOS 14.0, *) { - pictureInPictureController!.requiresLinearPlayback = true - } - pictureInPictureController!.delegate = pictureInPictureProxy - } - if !AVPictureInPictureController.isPictureInPictureSupported() && pictureInPictureButton != nil { - pictureInPictureButton.isHidden = true - } - if let currentItem = contentPlayhead?.player.currentItem { NotificationCenter.default.addObserver( self, @@ -442,9 +416,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe return } - guard let contentPlayer, - let pictureInPictureProxy - else { + guard let contentPlayer else { print("AD: ERROR: player is not ready to request ads") onComplete() return @@ -455,7 +427,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe adTagUrl: getAdTagUrl(), adDisplayContainer: createAdDisplayContainer(), avPlayerVideoDisplay: IMAAVPlayerVideoDisplay(avPlayer: contentPlayer), - pictureInPictureProxy: pictureInPictureProxy, + pictureInPictureProxy: nil, userContext: nil ) From efafe7d6a50dc6112e6b8dc9cfb6c8ea1a5965c1 Mon Sep 17 00:00:00 2001 From: Lucas Tonussi Date: Thu, 16 Jul 2026 16:00:56 -0300 Subject: [PATCH 4/4] Refactor ad request handling in AdsViewController - Replaced avPlayerVideoDisplay with contentPlayhead in the ad request to streamline the ad loading process. - Improved code clarity by simplifying the parameters passed to IMAAdsRequest. --- packages/ads/ios/smads/Sources/smads/AdsViewController.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift index 4b185847..c118ebed 100644 --- a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift +++ b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift @@ -426,8 +426,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe let request = IMAAdsRequest( adTagUrl: getAdTagUrl(), adDisplayContainer: createAdDisplayContainer(), - avPlayerVideoDisplay: IMAAVPlayerVideoDisplay(avPlayer: contentPlayer), - pictureInPictureProxy: nil, + contentPlayhead: contentPlayhead, // Passamos o playhead em vez do avPlayerVideoDisplay userContext: nil )