Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 119 additions & 53 deletions packages/ads/ios/smads/Sources/smads/AdsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -106,6 +164,7 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe

private func cleanupPlaybackResources() {
removePlaybackObservers()
detachFromOffScreenHost()

adsManager?.delegate = nil
adsManager?.destroy()
Expand All @@ -120,10 +179,6 @@ class AdsViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDe
contentPlayer = nil
contentPlayhead = nil

pictureInPictureController?.delegate = nil
pictureInPictureController = nil
pictureInPictureProxy = nil

contentPlayerLayer?.removeFromSuperlayer()
contentPlayerLayer = nil

Expand Down Expand Up @@ -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)
Comment on lines +222 to 225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file and nearby lines.
git ls-files packages/ads/ios/smads/Sources/smads/AdsViewController.swift
wc -l packages/ads/ios/smads/Sources/smads/AdsViewController.swift
sed -n '200,240p' packages/ads/ios/smads/Sources/smads/AdsViewController.swift

# Find other AVAudioSession category usages in the repo for context.
rg -n "setCategory\\(|allowAirPlay|allowBluetoothA2DP|playAndRecord|playback" packages/ads/ios -S

Repository: SuaMusica/flutter_plugins

Length of output: 1956


🌐 Web query:

AVAudioSession.CategoryOptions.allowAirPlay playback category allowed documentation

💡 Result:

The AVAudioSession.CategoryOptions.allowAirPlay option determines whether an audio session is permitted to stream audio to AirPlay devices [1][2]. Key documentation points regarding its usage: 1. Explicit vs. Implicit Setting: You can only explicitly set the allowAirPlay option when your audio session category is set to.playAndRecord [1][2]. For most other categories that support AirPlay, such as.playback,.ambient, and.soloAmbient, the system handles this option implicitly [1][2][3]. 2. Limitations: Audio sessions using the.multiRoute or.record categories implicitly clear (disable) this option [1][2]. 3. Interaction: Attempting to explicitly set allowAirPlay with categories that do not support it—or when it is otherwise restricted—may result in an error when configuring the audio session (e.g., OSStatus error -50) [4]. For most standard playback applications, you do not need to set this option manually, as the system enables it automatically for appropriate categories [1][2].

Citations:


🌐 Web query:

AVAudioSession.CategoryOptions allowBluetoothA2DP playback category valid documentation

💡 Result:

The AVAudioSession.CategoryOptions.allowBluetoothA2DP option is a valid and supported option for the AVAudioSession.Category.playback category [1][2]. Official Apple documentation specifies that the allowBluetoothA2DP option determines whether you can stream audio from a session to Bluetooth devices that support the Advanced Audio Distribution Profile (A2DP) [1][2]. Key details regarding its usage include: 1. Automatic Routing: The system automatically routes audio to A2DP ports when you configure an audio session to use the playback category [2]. 2. Compatibility: While enabled by default for playback, specifying this option explicitly ensures the availability of A2DP devices for high-quality, stereo output [2]. 3. Other Categories: Starting with iOS 10.0, this option is also valid when using the playAndRecord category [2]. 4. Conflicts: If both allowBluetoothA2DP and allowBluetooth (which enables the Hands-Free Profile/HFP) are set, the system prioritizes HFP ports for routing when a device supports both profiles [2]. You can apply this option by passing it to the options parameter when calling the setCategory(_:mode:options:) method on your AVAudioSession instance [3][4].

Citations:


Drop .allowAirPlay from this playback session. .playback already supports AirPlay routing implicitly; keep .allowBluetoothA2DP, but remove the AirPlay option to avoid setCategory errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ads/ios/smads/Sources/smads/AdsViewController.swift` around lines
222 - 225, Update the AVAudioSession options in the playback setup to remove
.allowAirPlay while retaining .allowBluetoothA2DP. Leave the existing category,
mode selection, and setActive behavior unchanged.

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)")
}
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Comment on lines +395 to +398

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

echo '--- line count ---'
wc -l "$FILE"

echo '--- around 360-470 ---'
sed -n '360,470p' "$FILE"

echo '--- search background-playback references ---'
rg -n "adsManager\.resume|resume\(\)|isOffScreenAdRequest|applicationState|background|Audio|AirPlay|requestAds\(with:" packages/ads/ios/smads/Sources/smads/AdsViewController.swift packages/ads/ios/smads/Sources -g '*.swift' -g '*.md' -g '*.txt'

Repository: SuaMusica/flutter_plugins

Length of output: 12589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

nl -ba "$FILE" | sed -n '380,455p'

Repository: SuaMusica/flutter_plugins

Length of output: 203


🌐 Web query:

Google IMA iOS background ad playback adsManager.resume content must be playing before requestAds background mode Audio AirPlay

💡 Result:

To enable background ad playback in the Google IMA SDK for iOS, you must strictly follow specific configuration and implementation requirements [1]. Google does not recommend playing video ads in the background; this functionality is intended for audio ads [1]. Key Requirements: 1. Enable Background Modes: In your Xcode project target, navigate to Capabilities > Background Modes and enable Audio and AirPlay [1]. Without this, audio playback stops when the app moves to the background, and the app cannot launch audio while backgrounded [1]. 2. Configure AVAudioSession: You must activate the AVAudioSession and set it to a category that supports background audio, such as AVAudioSessionCategoryPlayback [1]. 3. Enable Background Playback in IMA SDK: When creating your IMAAdsLoader, you must pass an IMASettings object with enableBackgroundPlayback set to YES [1][2]. 4. Content Playback Requirement: To make ad requests while your app is in the background, your content must already be playing [1]. If the app enters the background, you may need to manually ensure content playback is active before calling [IMAAdsLoader requestAds:] [1]. 5. Resume Ad Playback: Backgrounding an iOS app automatically pauses ad playback [1]. If an ad is playing when the app moves to the background, you must call [IMAAdsManager resume] to continue ad playback [1]. These steps allow the app to both request ads in the background and continue playing an ad that was already in progress when the app was backgrounded [1]. Failure to follow these steps, particularly the Audio and AirPlay capability and the IMASettings configuration, will result in ad playback halting upon backgrounding [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

echo '--- 340-375 ---'
sed -n '340,375p' "$FILE"

echo '--- 100-125 ---'
sed -n '100,125p' "$FILE"

echo '--- 755-780 ---'
sed -n '755,780p' "$FILE"

Repository: SuaMusica/flutter_plugins

Length of output: 2671


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

echo '--- lifecycle handlers ---'
rg -n "applicationDidBecomeActive|applicationWillEnterForeground|applicationDidEnterBackground|resume\(\)|pause\(\)|active =" "$FILE"

echo '--- around 300-370 ---'
sed -n '300,370p' "$FILE"

Repository: SuaMusica/flutter_plugins

Length of output: 3771


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

echo '--- requestAds call sites ---'
rg -n "\brequestAds\(\)" "$FILE"

echo '--- contentPlayer play references ---'
rg -n "contentPlayer.*play|\.play\(\)" "$FILE"

Repository: SuaMusica/flutter_plugins

Length of output: 455


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

echo '--- 70-100 ---'
sed -n '70,100p' "$FILE"

echo '--- 460-490 ---'
sed -n '460,490p' "$FILE"

echo '--- 585-610 ---'
sed -n '585,610p' "$FILE"

echo '--- 635-655 ---'
sed -n '635,655p' "$FILE"

Repository: SuaMusica/flutter_plugins

Length of output: 3563


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

echo '--- 140-185 ---'
sed -n '140,185p' "$FILE"

Repository: SuaMusica/flutter_plugins

Length of output: 1326


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE='packages/ads/ios/smads/Sources/smads/AdsViewController.swift'

echo '--- 185-260 ---'
sed -n '185,260p' "$FILE"

echo '--- 260-340 ---'
sed -n '260,340p' "$FILE"

Repository: SuaMusica/flutter_plugins

Length of output: 6193


Complete the off-screen resume flow. applicationDidEnterBackground() keeps off-screen ads marked active, so returning to foreground won’t call adsManager?.resume(). Also, requestAds() runs before the content player starts, which can leave background requests stuck. Start the content player before requesting ads and resume the ads manager for the off-screen path.

📍 Affects 1 file
  • packages/ads/ios/smads/Sources/smads/AdsViewController.swift#L395-L398 (this comment)
  • packages/ads/ios/smads/Sources/smads/AdsViewController.swift#L440-L440
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ads/ios/smads/Sources/smads/AdsViewController.swift` around lines
395 - 398, Complete the off-screen ad lifecycle in AdsViewController: in
applicationWillEnterForeground(), resume adsManager for off-screen ads even when
active remains true, and reorder the requestAds() flow so the content player
starts before ads are requested. Apply the related changes at
AdsViewController.swift lines 395-398 and 440, preserving existing behavior for
on-screen ads.

}

@objc func contentDidFinishPlaying(_ notification: Notification) {
Expand Down Expand Up @@ -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
Expand All @@ -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
)

Expand Down