This document outlines the Hybrid Integration Strategy for implementing Google In-Stream (Pre-roll/Mid-roll) video ads alongside the AppLovin MAX SDK in a Swift-based iOS application.
Implementation Guide: Google In-Stream Ads & AppLovin MAX Hybrid
Overview
To provide a premium video experience with native "Skip Ad" buttons and "Ad 1 of 2" indicators, we utilize the Google IMA (Interactive Media Ads) SDK specifically for in-stream video content, while leveraging AppLovin MAX for all other ad formats (Interstitials, App Open, etc.) to maximize global fill rates.
Technical Requirements
- iOS 13.0+
- AppLovin SDK (Standard Mediation)
- Google IMA iOS SDK (Direct Integration)
- CocoaPods for dependency management.
Dependency Configuration
Update your Podfile to include both SDKs. Note that there is no "MAX IMA Adapter"; these function as parallel libraries.
target 'YourAppTarget' do
use_frameworks!
# AppLovin MAX for General Mediation
pod 'AppLovinSDK'
pod 'AppLovinMediationGoogleAdapter'
##### Other mediation pods
# Google IMA for True In-Stream Video
pod 'GoogleAds-IMA-iOS-SDK'
endRun pod install --repo-update after saving.
Architectural Setup
The Google IMA SDK requires a Content Player (your AVPlayer) and an Ad Container (a transparent UIView) to overlay the ad UI.
Key Components:
- IMAAdsLoader: Requests ads from Google Ad Manager.
- IMAAdsManager: Handles playback logic, skip buttons, and click-throughs.
- AdDisplayContainer: Links the ad UI to your View Controller.
Implementation (Swift)
Step A: Class Setup & Initialization
import UIKit
import GoogleInteractiveMediaAds
import AVFoundation
class VideoPlayerViewController: UIViewController, IMAAdsLoaderDelegate, IMAAdsManagerDelegate {
// Video Content Player
var contentPlayer: AVPlayer?
@IBOutlet weak var videoPlayerView: UIView! // Where your movie plays
@IBOutlet weak var adContainerView: UIView! // Must sit ABOVE the player view
// IMA Objects
var adsLoader: IMAAdsLoader!
var adsManager: IMAAdsManager?
override func viewDidLoad() {
super.viewDidLoad()
setupAdsLoader()
}
func setupAdsLoader() {
adsLoader = IMAAdsLoader(settings: nil)
adsLoader.delegate = self
}
}Step B: Requesting In-Stream Ads (Pre-roll/Mid-roll)
Use a VMAP or VAST tag URL provided by Google Ad Manager. VMAP tags automatically handle Mid-roll timings.
func requestAds() {
let adDisplayContainer = IMAAdDisplayContainer(adContainer: adContainerView, viewController: self)
// Testing Tag (Single Skippable Inline)
let testTag = "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319475/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dlinearwithskip&cmsid=496&vid=short_onecue&correlator="
let request = IMAAdsRequest(
adTagUrl: testTag,
adDisplayContainer: adDisplayContainer,
contentPlayhead: nil,
userContext: nil)
adsLoader.requestAds(with: request)
}Step C: Coordinating Content & Ad State
You must manually pause your content when the ad starts and resume when it ends.
// IMA Delegate: Ad is ready
func adsLoader(_ loader: IMAAdsLoader, adsLoadedWith adsLoadedData: IMAAdsLoadedData) {
adsManager = adsLoadedData.adsManager
adsManager?.delegate = self
adsManager?.initialize(with: nil)
}
// IMA Delegate: Pause content for Ad
func adsManagerDidRequestContentPause(_ adsManager: IMAAdsManager) {
contentPlayer?.pause()
}
// IMA Delegate: Resume content after Ad
func adsManagerDidRequestContentResume(_ adsManager: IMAAdsManager) {
contentPlayer?.play()
}
func adsManager(_ adsManager: IMAAdsManager, didReceive event: IMAAdEvent) {
if event.type == .LOADED {
adsManager.start()
}
}Best Practices
- Z-Index: Ensure your adContainerView is the top-most layer in your storyboard/code.
- Error Handling: Always implement adsManager(_:didReceive:) for errors. If the ad fails to load, immediately call contentPlayer.play() so the user isn't stuck on a black screen.
- MAX Coexistence: Use MAX Interstitials for app transitions (e.g., leaving the video player) and Google IMA for the video timeline itself.
Handling "Skip" and Ad Completion Logic
To ensure a professional user experience, you must handle the logic for skipping ads properly. When a user clicks "Skip," the Google IMA SDK triggers specific events. Your app must listen for these to remove the ad UI and resume the video content immediately.
In the IMAAdsManagerDelegate, the didReceive event method is the command center for the ad lifecycle. You need to handle SKIPPED and ALL_ADS_COMPLETED to ensure the player resumes smoothly.
func adsManager(_ adsManager: IMAAdsManager, didReceive event: IMAAdEvent) {
switch event.type {
case .LOADED:
// Ad is ready to play
adsManager.start()
case .SKIPPED:
print("User skipped the ad.")
// The 'adsManagerDidRequestContentResume' delegate will typically
// follow this, but you can force resume logic here if needed.
resumeContent()
case .COMPLETED:
print("Ad played to the end.")
case .ALL_ADS_COMPLETED:
print("All ads in the VMAP/Post-roll are finished.")
resumeContent()
// Clean up the manager to free up memory
adsManager.destroy()
self.adsManager = nil
case .PAUSED:
print("Ad was paused (usually via user interaction).")
case .RESUMED:
print("Ad was resumed.")
default:
break
}
}
// Helper function to ensure content resumes correctly
func resumeContent() {
adContainerView.isHidden = true
contentPlayer?.play()
}Customizing the Skip UI
Google IMA allows some control over when the skip button appears (configured in your Google Ad Manager dashboard), but you can detect if an ad is skippable in your Swift code to adjust your underlying player UI (like hiding your custom seek bars).
func adsManager(_ adsManager: IMAAdsManager, didReceive event: IMAAdEvent) {
if event.type == .LOADED {
if event.ad?.isSkippable == true {
print("This ad can be skipped after \(event.ad?.skipTimeOffset ?? 0) seconds")
// You might want to show a small "Skip enabled" hint if using a custom UI
}
}
// ... handle other cases
}10. Important: Handling the "Learn More" Click-through
When a user clicks a "Learn More" or "Visit Site" link on a Google In-Stream ad, the SDK will automatically open a browser. You should ensure your app doesn't try to play content in the background while the user is looking at the ad's website.
- AppLovin MAX Note: If you have AppLovin Banners or MRECs visible on the same screen, it is best practice to hide or pause the AppLovin refresh timer while the Google In-Stream ad is expanded to avoid "Ad Collision" or technical lag.
Summary Checklist for Documentation
- Resume on Skip: Ensure SKIPPED triggers player.play().
- Clean up: Always call adsManager.destroy() when ads are finished to prevent memory leaks in the IMA SDK.
- UI Layering: Keep the adContainerView hidden when ads aren't playing so it doesn't intercept touches intended for your video player's play/pause buttons.
This is the consolidated Swift implementation. It combines Google IMA for the In-Stream (Pre/Mid-roll) logic with AppLovin MAX placeholders, ensuring they work in harmony without overlapping.
Consolidated VideoPlayerViewController.swift
import UIKit
import AVFoundation
import GoogleInteractiveMediaAds
import AppLovinSDK
class VideoPlayerViewController: UIViewController {
// MARK: - UI Elements
@IBOutlet weak var videoContainerView: UIView!
@IBOutlet weak var adContainerView: UIView! // Sits on top of videoContainerView
// MARK: - Content Player
var contentPlayer: AVPlayer?
var playerLayer: AVPlayerLayer?
// MARK: - Google IMA Properties (Path B)
var adsLoader: IMAAdsLoader!
var adsManager: IMAAdsManager?
let adTagURL = "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319475/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dlinearwithskip&cmsid=496&vid=short_onecue&correlator="
// MARK: - AppLovin MAX Properties
var interstitialAd: MAInterstitialAd!
override func viewDidLoad() {
super.viewDidLoad()
setupContentPlayer()
setupAppLovinMAX()
setupGoogleIMA()
}
// MARK: - Setup Methods
private func setupContentPlayer() {
guard let url = URL(string: "https://storage.googleapis.com/gvabox/video/vsi.mp4") else { return }
contentPlayer = AVPlayer(url: url)
playerLayer = AVPlayerLayer(player: contentPlayer)
playerLayer?.frame = videoContainerView.bounds
videoContainerView.layer.addSublayer(playerLayer!)
}
private func setupGoogleIMA() {
adsLoader = IMAAdsLoader(settings: nil)
adsLoader.delegate = self
}
private func setupAppLovinMAX() {
// AppLovin is used for non-In-Stream ads (e.g., Post-roll or Exit ads)
interstitialAd = MAInterstitialAd(adUnitIdentifier: "YOUR_MAX_INTER_ID")
interstitialAd.delegate = self
interstitialAd.load()
}
// MARK: - Ad Actions
@IBAction func startVideoTapped(_ sender: UIButton) {
requestInStreamAds()
}
private func requestInStreamAds() {
// Step 1: Create Ad Display Container
let adDisplayContainer = IMAAdDisplayContainer(adContainer: adContainerView, viewController: self)
// Step 2: Create Request
let request = IMAAdsRequest(
adTagUrl: adTagURL,
adDisplayContainer: adDisplayContainer,
contentPlayhead: nil,
userContext: nil)
// Step 3: Request from Google
adsLoader.requestAds(with: request)
}
}
// MARK: - Google IMA Delegate (In-Stream Logic)
extension VideoPlayerViewController: IMAAdsLoaderDelegate, IMAAdsManagerDelegate {
func adsLoader(_ loader: IMAAdsLoader, adsLoadedWith adsLoadedData: IMAAdsLoadedData) {
adsManager = adsLoadedData.adsManager
adsManager?.delegate = self
let adsRenderingSettings = IMAAdsRenderingSettings()
// Ensure skip button and UI are handled by IMA
adsManager?.initialize(with: adsRenderingSettings)
}
func adsLoader(_ loader: IMAAdsLoader, adsFailedWith adErrorData: IMAAdLoadingErrorData) {
print("IMA Error: \(adErrorData.adError.message ?? "Unknown Error")")
contentPlayer?.play() // Fallback: play content if ad fails
}
func adsManager(_ adsManager: IMAAdsManager, didReceive event: IMAAdEvent) {
switch event.type {
case .LOADED:
adContainerView.isHidden = false
adsManager.start()
case .SKIPPED, .COMPLETED:
print("Ad finished/skipped.")
case .ALL_ADS_COMPLETED:
print("All ads finished.")
cleanUpAds()
default:
break
}
}
func adsManager(_ adsManager: IMAAdsManager, didReceive error: IMAAdError) {
print("AdsManager Error: \(error.message ?? "Unknown")")
cleanUpAds()
}
func adsManagerDidRequestContentPause(_ adsManager: IMAAdsManager) {
contentPlayer?.pause()
}
func adsManagerDidRequestContentResume(_ adsManager: IMAAdsManager) {
cleanUpAds()
}
private func cleanUpAds() {
adContainerView.isHidden = true
contentPlayer?.play()
adsManager?.destroy()
adsManager = nil
}
}
// MARK: - AppLovin MAX Delegate (Interstitial Logic)
extension VideoPlayerViewController: MAAdDelegate {
func didLoad(_ ad: MAAd) {}
func didFailToLoadAd(forAdUnitIdentifier adUnitId: String, withError error: MAError) {}
func didDisplay(_ ad: MAAd) {
contentPlayer?.pause()
}
func didHide(_ ad: MAAd) {
contentPlayer?.play()
interstitialAd.load()
}
func didClick(_ ad: MAAd) {}
func didFail(toDisplay ad: MAAd, withError error: MAError) {
contentPlayer?.play()
}
}Key Highlights of this Code:
- Z-Index Management: The adContainerView is toggled using isHidden. Ensure this view is above the videoContainerView in your Storyboard.
- Dual Resilience: If Google IMA fails (adsFailedWith), the code immediately resumes your video content so the user experience isn't broken.
- AppLovin Interstitial: Included as a delegate to show how you would pause your content if you ever triggered a standard MAX Interstitial (e.g., when the user tries to exit the video).
- Automatic Skipping: The SKIPPED event is caught, and cleanUpAds() ensures the UI is hidden and playback resumes.
Updated Delegate Logic (With Skip Handling)
This logic ensures that if a user clicks Skip, the ad UI is immediately destroyed and your content resumes without a "black screen" hang.
// MARK: - IMAAdsManagerDelegate
extension VideoPlayerViewController: IMAAdsManagerDelegate {
func adsManager(_ adsManager: IMAAdsManager, didReceive event: IMAAdEvent) {
switch event.type {
case .LOADED:
// Ad is ready, clear any custom loading indicators
adsManager.start()
case .SKIPPED:
print("IMA Event: User Skipped Ad")
// Crucial: Clean up immediately to resume content
finalizeAdBreak()
case .TAPPED:
print("IMA Event: User tapped ad (Visit Website)")
// App normally goes to background here; content remains paused.
case .ALL_ADS_COMPLETED:
print("IMA Event: All ads in sequence finished")
finalizeAdBreak()
case .CONTENT_PAUSE_REQUESTED:
// The SDK found a mid-roll or pre-roll point
pauseContentForAd()
case .CONTENT_RESUME_REQUESTED:
// The SDK finished its current ad break
finalizeAdBreak()
default:
break
}
}
// MARK: - Content Coordination
private func pauseContentForAd() {
adContainerView.isHidden = false
contentPlayer?.pause()
// Optional: Hide your custom video player controls here
}
private func finalizeAdBreak() {
adContainerView.isHidden = true
contentPlayer?.play()
// Destroy manager to prevent memory leaks and reset for next mid-roll
adsManager?.destroy()
adsManager = nil
}
}Important Implementation Tips
- AVPlayer Coordination: The Google IMA SDK uses IMAAVPlayerContentPlayhead to watch your video time. For mid-rolls to work, ensure you initialize the ads loader with your playhead:
let contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: contentPlayer!)
let request = IMAAdsRequest(adTagUrl: tag, adDisplayContainer: container, contentPlayhead: contentPlayhead, userContext: nil)Friendly Obstructions: If you have a custom UI (like a "Close" button for your player) that overlays the ad, you must register it as a "Friendly Obstruction" in the IMAAdDisplayContainer, or Google may flag it as invalid traffic.
AppLovin Coexistence: Since AppLovin MAX also listens for app state, ensure you don't call interstitial.show() while adsManager is active. Use a simple boolean var isAdPlaying = false to track state between the two SDKs.
Please contact your Freestar Support Team if you need any assistance along the way. We are here to support you!