Custom Paused Ads

Build custom Pause Ads on iPad with full-screen, transparent WebView, and VAST formats. Configure resume buttons, prefetch ad content, and dismiss ads programmatically.

What are Pause Ads

Pause Ads display brand messages during natural breaks in playback. When a viewer pauses live or on-demand content, an ad appears on-screen after a brief delay. This format provides a high-visibility, static canvas that integrates naturally with the viewing experience. Pause Ads are effective for driving awareness, shoppability, and branded interactions.

Key Characteristics

  • Trigger: The viewer pauses live or on-demand content.
  • Delay: Configurable — from instant display (with prefetching) to any custom duration. A 3–5 second delay is common to filter out accidental pauses.
  • Display: A static brand creative fills the screen, or appears as a transparent overlay over the player.
  • Resume: Resuming playback automatically dismisses the ad, or use hidePausedAd() for programmatic dismissal.
  • Customization: Resume button content, position, and touch-pass-through are fully customizable.
  • Frequency: Advertisers can rotate up to 10 creatives per content session.
  • Platform: Available on iPad. Calls are no-ops on iPhone.

Prerequisites

Before using Pause Ads, ensure you have:

  1. Completed the iOS Integration Guide
  2. Called StreamLayer.createSession(for: "<event_id>") to establish an active session

The event session must be created before displaying pause ads, as it provides necessary context for analytics tracking.

// Create a session for the event
StreamLayer.createSession(for: "your-event-id")

The SDK also requires a displayDelegate on managedAdManager to present the ad. This is configured automatically when you call StreamLayer.createOverlay(...).

Delay Pause Ads

Delay displaying pause ads to avoid brief or accidental pauses. Typically show a pause ad 3–5 seconds after the user pauses playback. If playback doesn't resume within this window, treat the pause as intentional and present the ad. The example below schedules the ad after 5 seconds.

Use a DispatchWorkItem to defer the call to showPausedAd(_:) and cancel it when playback resumes. If the ad has already been presented before the user resumes, dismiss it with hidePausedAd().

private var pausedAdWorkItem: DispatchWorkItem?

func handlePlayingState() {
    pausedAdWorkItem?.cancel()
    pausedAdWorkItem = nil
    StreamLayer.hidePausedAd()
}

func handlePausedState() {
    let workItem = DispatchWorkItem { [weak self] in
        guard self?.pausedAdWorkItem?.isCancelled == false else { return }
        self?.presentPausedAd()
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem)
    self.pausedAdWorkItem = workItem
}

private func presentPausedAd() {
    // Pick whichever pause-ad type fits your inventory — see "Displaying a Custom Pause Ad" below
    let vastURL = URL(string: "https://example.com/vast.xml")!
    StreamLayer.showPausedAd(.transparentBackgroundVAST(
        vastTagURL: vastURL,
        resumeButton: .hidden
    ))
}

Displaying a Custom Pause Ad

Call StreamLayer.showPausedAd(_:) to present a promotional StreamLayer Element when the user pauses video playback. The Element includes a resume button that dismisses the ad and continues playback.

The adType parameter selects one of three formats:

  • .fullScreen(configuration:) — A full-bleed ad with customizable title, body, sponsor logo, and background image.
  • .transparentBackground(url:slot:resumeButton:bypassTouches:) — A transparent overlay rendered in a WKWebView. Pass url for a direct creative URL, or slot to let the bell-ad proxy build the URL from an ad-server slot identifier.
  • .transparentBackgroundVAST(vastTagURL:resumeButton:bypassTouches:) — A transparent overlay that parses a VAST tag, renders the NonLinear / Companion static image, fires <Impression> pixels on display, and fires <NonLinearClickTracking> pixels on user tap.

Important:

  • Set the displayDelegate on managedAdManager before calling this method. The delegate is configured automatically when you call createOverlay().
  • iOS SDK: Pause Ads are only available on iPad. Calling this method on iPhone will have no effect.
  • tvOS SDK: Pause Ads are fully supported on tvOS — see the tvOS Custom Paused Ads guide.

Full Screen Pause Ad

func handlePausedState() {
    // Show a fullscreen pause ad with custom configuration
    let config = SLRPausedAdConfiguration(
        title: "Sponsored Content",
        body: "Check out our latest offers",
        sponsorLogo: UIImage(named: "sponsor_logo"),
        backgroundImage: UIImage(named: "ad_background")
    )
    StreamLayer.showPausedAd(.fullScreen(configuration: config))
}

You can also hydrate a full-screen ad from a VAST tag — pass the tag URL via SLRPausedAdConfiguration.vastTagURL and the SDK will parse it and apply the extracted creative on top of your configuration.

Transparent WebView Pause Ad

Use .transparentBackground for HTML creative loaded in a WKWebView. Provide either a direct creative url or a slot identifier resolved by the SDK's bell-ad proxy.

func handlePausedState() {
    // Direct creative URL
    let adURL = URL(string: "https://example.com/pause-ad.html")!
    StreamLayer.showPausedAd(.transparentBackground(
        url: adURL,
        resumeButton: .hidden,
        bypassTouches: false
    ))

    // Or let the bell-ad proxy resolve an ad-server slot
    StreamLayer.showPausedAd(.transparentBackground(
        slot: "/12345/pause_ad_ctv",
        resumeButton: .hidden
    ))
}

Pass exactly one of url or slot. The slot form opts the request into the SDK's bell-ad proxy, which handles ad-server resolution and analytics for you.

Transparent VAST Pause Ad

Use .transparentBackgroundVAST when your ad server returns a VAST 4.x tag with a NonLinear or Companion static image. The SDK:

  1. Fetches and parses the VAST XML.
  2. Downloads the static image referenced by the NonLinear / Companion creative.
  3. Renders the image as a transparent overlay over the player.
  4. Fires the VAST <Impression> pixels once at render time (per the VAST 4.x spec — impressions count when the ad is visible, not when the tag is parsed).
  5. On user tap, fires the <NonLinearClickTracking> pixels and opens the click-through URL in the system browser.
func handlePausedState() {
    let vastURL = URL(string: "https://example.com/vast.xml")!
    StreamLayer.showPausedAd(.transparentBackgroundVAST(
        vastTagURL: vastURL,
        resumeButton: .hidden,
        bypassTouches: false
    ))
}

If the VAST response contains no usable static image, the SDK logs an error and skips presentation — playback is not interrupted.

Resume Button Configuration

The resume button can be fully customized for transparent background pause ads using SLRResumeButtonConfiguration. You can control both the button's content (image, text, or hidden) and its position on screen.

API Structure

/// Configuration for the resume button displayed on transparent background paused ads
public struct SLRResumeButtonConfiguration {
    /// Button content type
    public enum Content {
        /// Display resume button with a custom image and size
        case image(UIImage, size: CGSize)

        /// Display resume button with custom text
        case text(String)

        /// Hide the resume button completely
        case hidden
    }

    /// Predefined positions for the resume button
    public enum Position {
        case topLeft(insets: UIEdgeInsets)
        case topRight(insets: UIEdgeInsets)
        case bottomLeft(insets: UIEdgeInsets)
        case bottomRight(insets: UIEdgeInsets)
        case center
        case custom((UIView, UIView) -> Void) // Custom closure for complete control

        /// Default position for landscape iPad
        public static var defaultLandscape: Position

        /// Default position for portrait iPad/iPhone
        public static var defaultPortrait: Position
    }

    public let content: Content
    public let position: Position
}

Basic Usage

let vastURL = URL(string: "https://example.com/vast.xml")!

// Default position with custom image
let config = SLRResumeButtonConfiguration.image(
    UIImage(named: "play_button")!,
    size: CGSize(width: 100, height: 100)
)
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: config))

// Default position with text
let textConfig = SLRResumeButtonConfiguration.text("RESUME")
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: textConfig))

// Hidden button
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: .hidden))

The same configuration applies to .transparentBackground(url:) and .transparentBackground(slot:) — pass the resume button via the resumeButton: argument.

Predefined Positions

Position the button at common screen locations with customizable insets:

// Top-left corner
let topLeftConfig = SLRResumeButtonConfiguration(
    content: .text("RESUME"),
    position: .topLeft(insets: UIEdgeInsets(top: 60, left: 80, bottom: 0, right: 0))
)
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: topLeftConfig))

// Top-right corner
let topRightConfig = SLRResumeButtonConfiguration.image(
    UIImage(systemName: "play.circle.fill")!,
    size: CGSize(width: 80, height: 80),
    position: .topRight(insets: UIEdgeInsets(top: 60, left: 0, bottom: 0, right: 80))
)
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: topRightConfig))

// Bottom-right corner
let bottomRightConfig = SLRResumeButtonConfiguration(
    content: .text("Continue"),
    position: .bottomRight(insets: UIEdgeInsets(top: 0, left: 0, bottom: 80, right: 80))
)
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: bottomRightConfig))

// Center of screen
let centerConfig = SLRResumeButtonConfiguration.text(
    "Resume Video",
    position: .center
)
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: centerConfig))

Custom Positioning with Closure

For complete control over button placement and styling, use the .custom position with a closure:

let customConfig = SLRResumeButtonConfiguration(
    content: .image(playIcon, size: CGSize(width: 100, height: 100)),
    position: .custom { button, containerView in
        // Apply SnapKit constraints
        button.snp.makeConstraints { make in
            make.trailing.equalToSuperview().offset(-150)
            make.centerY.equalToSuperview().offset(50)
            make.width.height.equalTo(100)
        }

        // Optional: Add custom styling
        button.layer.cornerRadius = 50
        button.layer.shadowColor = UIColor.black.cgColor
        button.layer.shadowOpacity = 0.3
        button.layer.shadowOffset = CGSize(width: 0, height: 4)
        button.layer.shadowRadius = 8
        button.backgroundColor = UIColor.white.withAlphaComponent(0.2)
    }
)
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: customConfig))

Platform-Specific Defaults

The SDK provides orientation-aware default positions for iPad:

iOS (iPad):

  • .defaultLandscape — Bottom-left: (60pt left, 64pt from bottom)
  • .defaultPortrait — Bottom-left: (16pt left, 16pt from bottom)

These defaults are automatically applied when you don't specify a position parameter.

Hidden Button Behavior

The .hidden configuration hides the resume button:

iOS:

  • Button is completely hidden
  • Background tap gesture is enabled (tap anywhere outside the ad surface to resume)

tvOS:

  • Button is completely hidden
  • Remains in view hierarchy for focus handling
  • Still responds to interaction when focused
// Hide the button (user can tap anywhere to resume on iOS)
StreamLayer.showPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL, resumeButton: .hidden))

bypassTouches Behavior

Both .transparentBackground and .transparentBackgroundVAST accept a bypassTouches: Bool argument:

  • false (default) — The overlay absorbs all touches. Taps anywhere route to the ad surface and resume button.
  • true — Taps that miss the ad surface pass through to the host UI underneath (for example, the video player controls). Taps on the ad surface and resume button still work as expected.
// Allow the host app's player controls to remain interactive while the ad is shown
StreamLayer.showPausedAd(.transparentBackgroundVAST(
    vastTagURL: vastURL,
    resumeButton: .hidden,
    bypassTouches: true
))

Prefetching Pause Ads

For improved user experience, prefetch pause ads before displaying them. This method downloads and caches VAST tag content and associated images, ensuring smooth presentation when the user pauses playback.

/// Prefetches a paused ad by parsing the VAST tag URL and downloading any associated images.
///
/// The prefetching process includes:
/// - Parsing the VAST tag XML
/// - Downloading and caching any images referenced in the VAST response
/// - Validating the ad content
/// - Caching with automatic expiration after the specified interval
///
/// - Parameters:
///   - adType: The type of paused ad to prefetch
///   - expirationInterval: Time in seconds after which the cached ad expires (default: 3600 = 1 hour)
///   - completion: A completion handler called when prefetching completes
public class func prefetchPausedAd(
    _ adType: SLRPausedAdType,
    expirationInterval: TimeInterval = 3600,
    completion: @escaping (Swift.Result<Void, Error>) -> Void
)

Prefetch behavior depends on the ad type:

  • .fullScreen(configuration:) — Prefetches the configuration's images, plus any image extracted from the optional vastTagURL.
  • .transparentBackground(url:slot:...) — No-op. The WKWebView handles its own loading; the completion fires immediately with .success(()).
  • .transparentBackgroundVAST(vastTagURL:...) — Parses the VAST tag and pre-downloads the NonLinear / Companion static image. Impression and click-tracking URLs are cached but not yet fired (they fire on display / tap).

Examples

// Prefetch a VAST pause ad with default 1-hour expiration
let vastURL = URL(string: "https://example.com/vast.xml")!
StreamLayer.prefetchPausedAd(.transparentBackgroundVAST(vastTagURL: vastURL)) { result in
    switch result {
    case .success:
        print("Ad cached for 1 hour")
    case .failure(let error):
        print("Prefetch failed: \(error)")
    }
}

// Prefetch with custom 30-minute expiration
StreamLayer.prefetchPausedAd(
    .transparentBackgroundVAST(vastTagURL: vastURL),
    expirationInterval: 1800
) { result in
    print("Ad cached for 30 minutes")
}

// Prefetch with no expiration (cache until manually cleared)
StreamLayer.prefetchPausedAd(
    .transparentBackgroundVAST(vastTagURL: vastURL),
    expirationInterval: .infinity
) { result in
    print("Ad cached indefinitely")
}

// Prefetch fullscreen ad with configuration (and optional VAST hydration)
let config = SLRPausedAdConfiguration(
    title: "Sponsored Content",
    body: "Check out our latest offers",
    sponsorLogo: UIImage(named: "sponsor_logo"),
    backgroundImage: UIImage(named: "ad_background"),
    vastTagURL: URL(string: "https://example.com/vast.xml")
)
StreamLayer.prefetchPausedAd(.fullScreen(configuration: config)) { result in
    switch result {
    case .success:
        print("Fullscreen ad prefetched successfully")
    case .failure(let error):
        print("Prefetch failed: \(error)")
    }
}

Note: When a prefetched ad is presented via showPausedAd(_:), the cache is automatically cleared after the ad is displayed. This ensures fresh content on subsequent pauses.

Clearing Prefetched Ads

Use this method to free up memory by removing cached ad content. After calling this method, subsequent calls to showPausedAd(_:) will need to re-fetch and parse VAST tags.

/// Clears all prefetched paused ad data from memory.
///
/// Use this method to free up memory by removing cached ad content.
///
/// - Note: This only clears the in-memory cache. The underlying image cache managed by
///   the image loading system is not affected.
public class func clearPrefetchedPausedAds()

Example

// Clear prefetched ads when the user navigates away or to free up memory
StreamLayer.clearPrefetchedPausedAds()

Note: The cache is automatically cleared when a prefetched ad is presented, so manual clearing is typically only needed when:

  • The user exits the viewing session
  • You need to free up memory
  • You want to force fresh content to be fetched

Dismissal

User-Initiated Dismissal

The user dismisses the pause ad by tapping the resume button. This action triggers the func playVideo(_ userInitiated: Bool) method on SLROverlayDelegate.

Implementation Example

extension YourViewController: SLROverlayDelegate {
    func playVideo(_ userInitiated: Bool) {
        // Resume video playback
        player.play()

        // Cancel any pending ad display
        pausedAdWorkItem?.cancel()
        pausedAdWorkItem = nil
    }
}

The userInitiated parameter indicates whether the action was triggered by the user (true) or programmatically (false), allowing you to handle analytics or logging accordingly.

Programmatic Dismissal

You can manually dismiss a currently displayed pause ad using StreamLayer.hidePausedAd(). This is useful when you need to dismiss the ad based on custom logic or external events.

/// Manually dismisses the currently displayed paused ad.
///
/// Use this method to programmatically hide a paused ad that is currently being shown.
/// This is useful when you need to dismiss the ad based on custom logic or user actions
/// outside of the standard resume/play button.
///
/// - Note: This method has no effect if no paused ad is currently displayed.
/// - Note: iOS SDK - This feature is only available on iPad. Calling this method on iPhone will have no effect.
public class func hidePausedAd()

Example

// Dismiss the ad when receiving a specific notification
NotificationCenter.default.addObserver(forName: .userDidPerformAction, object: nil, queue: .main) { _ in
    StreamLayer.hidePausedAd()
}

// Dismiss the ad after a timeout
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
    StreamLayer.hidePausedAd()
}

// Dismiss the ad based on custom business logic
func handleCustomEvent() {
    if shouldDismissAd {
        StreamLayer.hidePausedAd()
        player.play()
    }
}

Related