diff --git a/packages/ads/ios/smads/Sources/smads/AdsViewController.swift b/packages/ads/ios/smads/Sources/smads/AdsViewController.swift index 5768fe46..c118ebed 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! @@ -31,10 +27,10 @@ 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! - @IBOutlet weak var pictureInPictureButton: UIButton! override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) @@ -55,43 +51,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 +164,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe private func cleanupPlaybackResources() { removePlaybackObservers() + detachFromOffScreenHost() adsManager?.delegate = nil adsManager?.destroy() @@ -120,10 +179,6 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe contentPlayer = nil contentPlayhead = nil - pictureInPictureController?.delegate = nil - pictureInPictureController = nil - pictureInPictureProxy = nil - contentPlayerLayer?.removeFromSuperlayer() contentPlayerLayer = nil @@ -155,12 +210,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)") } @@ -218,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, @@ -257,6 +293,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. @@ -300,7 +366,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) { @@ -342,14 +411,12 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe } func requestAds() { - guard self.active else { + guard self.active || isOffScreenAdRequest() else { onComplete() return } - guard let contentPlayer, - let pictureInPictureProxy - else { + guard let contentPlayer else { print("AD: ERROR: player is not ready to request ads") onComplete() return @@ -359,8 +426,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe let request = IMAAdsRequest( adTagUrl: getAdTagUrl(), adDisplayContainer: createAdDisplayContainer(), - avPlayerVideoDisplay: IMAAVPlayerVideoDisplay(avPlayer: contentPlayer), - pictureInPictureProxy: pictureInPictureProxy, + contentPlayhead: contentPlayhead, // Passamos o playhead em vez do avPlayerVideoDisplay userContext: nil )