Exposed Ads

Display host-driven exposed ad units on tvOS on demand — full-bleed, sidebar, PIP, side-by-side, sponsor frame, transparent, and VAST — with step-by-step Swift examples.

Exposed ads are ad units your Host App displays on demand — at any moment your app chooses. Your app controls both when the ad appears and what it contains: you call showAd(_:) or showVASTAd(_:vastURL:) and pass the creative or a VAST tag. Common triggers include a paused player, a break between plays, or a moment in your UI — but the timing is entirely yours.

Exposed ads vs. paused ads. Exposed ads are host-driven — your app decides when to show one and supplies the content. Paused ads are a separate, StreamLayer-delivered format: StreamLayer serves an ad automatically on an ad break, sourced from Studio or Prebid, and you only signal pause/resume. See Paused Ads (tvOS).

There are two entry points, both on the StreamLayer class:

DeliveryMethodYou provide
Plain exposed adStreamLayer.showAd(_:)The ad unit and its creative/config (SLRAdType).
VAST exposed adStreamLayer.showVASTAd(_:vastURL:)A layout shape plus a VAST tag URL; the SDK parses and renders it.

Platform note: This guide covers the tvOS SDK. iOS exposes the same two methods with platform-specific ad units — see Exposed Ads (iOS). Key iOS vs tvOS differences are called out in Platform differences below.

Prerequisites

Before displaying an exposed ad:

  1. Complete the tvOS Integration Guide — install the SDK and obtain your SDK API Key.
  2. Create an event session so ad analytics have context:
StreamLayer.createSession(for: "your-event-id")
  1. Create the StreamLayer Element. createOverlay(...) wires the display delegate that presents exposed ads — showAd(_:) and showVASTAd(_:vastURL:) are no-ops until it has been called.
let slrViewController = StreamLayer.createOverlay(
    containerViewController: self,
    contentView: containerView,
    delegate: self
)

Exposed ads are fully supported on all tvOS devices.

Quick Start

Import the SDK and show an ad unit whenever your app needs to:

import StreamLayerSDK

func showExposedAd() {
    let config = SLRSidebarPromotionAdConfiguration(
        media: SLRAdMedia(image: .url(URL(string: "https://cdn.example.com/ad.jpg")!)),
        title: "Special offer",
        body: "Today only — 30% off",
        cta: SLRAdCTA(url: URL(string: "https://example.com/offer")!, title: "Scan to shop")
    )
    StreamLayer.showAd(.sidebarPromotion(configuration: config))
}

The ad appears in the StreamLayer Element with a resume button. When the viewer dismisses it, the SDK calls streamLayerDelegateAdDidFinish(_:) on your StreamLayerTVOSDelegate, passing an SLRAdInfo — resume playback when info.isPausedAd is true. The optional streamLayerDelegateAdDidStart(_:) fires when an ad begins.

Choosing when to show an ad

Because exposed ads are on demand, your app decides the trigger. Call showAd(_:) / showVASTAd(_:vastURL:) from wherever fits your product. Dismiss with hideAd(), and resume your video when the SDK reports the ad finished:

extension YourViewController: StreamLayerTVOSDelegate {
    // Optional — a StreamLayer ad began presenting
    func streamLayerDelegateAdDidStart(_ info: SLRAdInfo) {}

    // Required — resume playback when the ad paused the stream
    func streamLayerDelegateAdDidFinish(_ info: SLRAdInfo) {
        if info.isPausedAd {
            player.play()
        }
    }

    func streamLayerDelegateUpdateDuckingState(_ enabled: Bool) {
        player.volume = enabled ? 0.2 : 1.0
    }
}

To let StreamLayer deliver a paused ad automatically when the viewer pauses (sourced from Studio or Prebid), use Paused Ads (tvOS) instead of calling showAd yourself.


Plain exposed ads — showAd(_:)

Pass an SLRAdType case describing the ad unit. On tvOS, a unit's call-to-action renders as a scannable QR code rather than a tappable button.

public class func showAd(_ adType: SLRAdType)

Sidebar ad unit

.sidebarPromotion squeezes the video and shows the ad in a side panel. Supplying a banner promotes the layout to an L-bar (side panel plus a bottom banner).

// Sidebar
let sidebar = SLRSidebarPromotionAdConfiguration(
    media: SLRAdMedia(image: .url(URL(string: "https://cdn.example.com/ad.jpg")!)),
    title: "Special offer",
    body: "Today only — 30% off",
    cta: SLRAdCTA(url: URL(string: "https://example.com/offer")!, title: "Scan to shop")
)
StreamLayer.showAd(.sidebarPromotion(configuration: sidebar))

// L-bar — add a bottom banner
let lbar = SLRSidebarPromotionAdConfiguration(
    media: SLRAdMedia(image: .url(URL(string: "https://cdn.example.com/ad.jpg")!)),
    title: "Special offer",
    cta: SLRAdCTA(url: URL(string: "https://example.com/offer")!, title: "Scan to shop"),
    banner: SLRAdBanner(
        image: .url(URL(string: "https://cdn.example.com/banner.jpg")!),
        tapURL: URL(string: "https://example.com")
    )
)
StreamLayer.showAd(.sidebarPromotion(configuration: lbar))

Set isPaused: true to present the sidebar as a paused ad: it shows a RESUME control, and the SDK reports info.isPausedAd == true on the ad-lifecycle callbacks so your app resumes the stream when the ad finishes. This is the only promotion unit with paused UI — picture-in-picture, side-by-side, and sponsor frame render beside the still-playing stream.

let pausedSidebar = SLRSidebarPromotionAdConfiguration(
    media: SLRAdMedia(image: .url(URL(string: "https://cdn.example.com/ad.jpg")!)),
    title: "Special offer",
    cta: SLRAdCTA(url: URL(string: "https://example.com/offer")!, title: "Scan to shop"),
    isPaused: true
)
StreamLayer.showAd(.sidebarPromotion(configuration: pausedSidebar))

Picture-in-Picture ad unit

.pictureInPicturePromotion shrinks the video into a floating window and shows the ad — sized larger than the video — behind it. Supply a video in the media for an autoplaying clip.

let config = SLRPictureInPicturePromotionAdConfiguration(
    media: SLRAdMedia(
        image: .url(URL(string: "https://cdn.example.com/poster.jpg")!),
        video: SLRAdVideo(url: URL(string: "https://cdn.example.com/promo.mp4")!)
    ),
    title: "Now streaming",
    cta: SLRAdCTA(url: URL(string: "https://example.com")!, title: "Scan to watch"),
    sponsorLogo: .url(URL(string: "https://cdn.example.com/logo.png")!)
)
StreamLayer.showAd(.pictureInPicturePromotion(configuration: config))

Side-by-Side ad unit

.sideBySidePromotion places the ad next to the video at the same size. It takes the same configuration as Picture-in-Picture.

let config = SLRSideBySidePromotionAdConfiguration(
    media: SLRAdMedia(
        image: .url(URL(string: "https://cdn.example.com/poster.jpg")!),
        video: SLRAdVideo(url: URL(string: "https://cdn.example.com/promo.mp4")!)
    ),
    title: "Now streaming",
    cta: SLRAdCTA(url: URL(string: "https://example.com")!, title: "Scan to watch"),
    sponsorLogo: .url(URL(string: "https://cdn.example.com/logo.png")!)
)
StreamLayer.showAd(.sideBySidePromotion(configuration: config))

Sponsor Frame ad unit

.sponsorFrame is a squeeze-back frame: the video shrinks and a background image, title, and sponsor logo fill the surrounding frame. sideAlignment places the content on the .left or .right.

let config = SLRSponsorFrameAdConfiguration(
    title: "Brought to you by Acme",
    sponsorLogo: .url(URL(string: "https://cdn.example.com/logo.png")!),
    cta: SLRAdCTA(url: URL(string: "https://example.com")!, title: "Scan to learn more"),
    background: .url(URL(string: "https://cdn.example.com/frame_bg.jpg")!),
    sideAlignment: .right
)
StreamLayer.showAd(.sponsorFrame(configuration: config))

Full-Bleed ad unit

.fullBleed fills the screen with a static creative. layout controls composition: .left (title/body left-aligned), .center (centered), or .image (background image only, no text). Add a qrCode so viewers can act on the ad from their phone.

let config = SLRFullBleedAdConfiguration(
    title: "Sponsored Content",
    body: "Check out our latest offers",
    sponsorLogo: UIImage(named: "sponsor_logo"),
    backgroundImage: UIImage(named: "ad_background"),
    qrCode: .init(label: "Scan to learn more", url: "https://example.com"),
    layout: .left
)
StreamLayer.showAd(.fullBleed(configuration: config))

Transparent Background ad unit

.transparentBackground renders a creative over the player from a url, which may point at an HTML creative or a VAST tag — the SDK parses VAST tags directly, so there is no separate VAST case on tvOS. Customize the resume button and touch pass-through — see Resume button.

StreamLayer.showAd(.transparentBackground(
    url: URL(string: "https://example.com/vast.xml")!,
    resumeButton: .text("RESUME")
))

To render VAST inside a promotion shape (sidebar, PIP, side-by-side, sponsor frame, full-bleed) instead of over the full player, use showVASTAd(_:vastURL:).


VAST exposed ads

When your ad server returns a VAST tag, showVASTAd(_:vastURL:) renders it inside a promotion shape. The SDK fetches, parses, and displays the creative and handles VAST tracking for you.

public class func showVASTAd(_ shape: SLRExposedVASTShape, vastURL: URL)

SLRExposedVASTShape selects the layout. The sidebar, pictureInPicture, and sideBySide shapes accept an SLRExposedVASTConfiguration that can carry a host background image:

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

// Sidebar with a host-provided background
StreamLayer.showVASTAd(
    .sidebar(SLRExposedVASTConfiguration(
        background: .url(URL(string: "https://cdn.example.com/bg.jpg")!)
    )),
    vastURL: tag
)

// Picture-in-Picture
StreamLayer.showVASTAd(.pictureInPicture(), vastURL: tag)

// Side-by-Side
StreamLayer.showVASTAd(.sideBySide(), vastURL: tag)

The sponsorFrame and fullBleed shapes take no configuration — the VAST creative fills the frame, so a host background cannot be supplied. For sponsor frame, choose the content side with sideAlignment:

StreamLayer.showVASTAd(.sponsorFrame(sideAlignment: .right), vastURL: tag)
StreamLayer.showVASTAd(.fullBleed, vastURL: tag)
VAST shapeConfigurable backgroundNotes
.sidebarAdd nothing else for a plain sidebar
.pictureInPicture
.sideBySide
.sponsorFrame(sideAlignment:)VAST creative fills the frame
.fullBleedtvOS only; VAST creative fills the screen

Prebid ad units

Prebid exposed ads run a header-bidding auction and render the winning VAST creative. Request one on demand through showAd(.prebid(_:)):

StreamLayer.showAd(.prebid(
    SLRPrebidPlacement(
        storedRequestId: "sl-pause-tvos",
        demandSignals: { SLRPrebidDemandSignals.limited }
    )
))

SLRPrebidPlacement fields:

  • storedRequestId — the stored request configured for your placement in the auction backend.
  • demandSignals — a closure returning per-request signals (advertising identifier, limit-ad-tracking flag). Return SLRPrebidDemandSignals.limited when the user has not granted tracking authorization.
  • cannedResponse — an optional SLRPrebidCannedResponse for testing against a stored auction response.
// Testing against a stored auction response
StreamLayer.showAd(.prebid(
    SLRPrebidPlacement(
        storedRequestId: "sl-pause-tvos",
        cannedResponse: SLRPrebidCannedResponse(
            storedAuctionResponseId: "mock_video_1",
            bidderPlacements: ["appnexus": 13144370]
        )
    )
))

showAd(.prebid) runs a fresh auction each call. To have StreamLayer preload a Prebid ad and present it automatically on each ad break, set it as the paused-ad delivery source with setPausedAdDelivery(.prebid(_:)) — see Paused Ads (tvOS).


Prefetching and lifecycle

Prefetch

Prefetch caches an ad's VAST content and images so it appears instantly. Use prefetchAd(_:expirationInterval:completion:) for SLRAdType units and prefetchVASTAd(vastURL:expirationInterval:completion:) for VAST tags. Both default to a one-hour expiration.

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

// Prefetch a VAST tag with the default 1-hour expiration
StreamLayer.prefetchVASTAd(vastURL: vastURL) { result in
    switch result {
    case .success:
        print("Ad cached for 1 hour")
    case .failure(let error):
        print("Prefetch failed: \(error)")
    }
}

// Prefetch a transparent ad unit with a custom 30-minute expiration
StreamLayer.prefetchAd(
    .transparentBackground(url: vastURL),
    expirationInterval: 1800
) { _ in }

A prefetched ad's cache is cleared automatically once it has been presented, so the next request fetches fresh content.

Clear the cache

StreamLayer.clearPrefetchedAds()

Dismiss programmatically

hideAd() dismisses the currently displayed exposed ad. It is a no-op when no ad is visible.

StreamLayer.hideAd()

Resume playback

The SDK dismisses the ad when the viewer selects the resume button and calls streamLayerDelegateAdDidFinish(_:) on your StreamLayerTVOSDelegate, passing an SLRAdInfo. Resume playback when info.isPausedAd is true. The SDK also calls the optional streamLayerDelegateAdDidStart(_:) when an ad begins presenting.

extension YourViewController: StreamLayerTVOSDelegate {
    // Optional — a StreamLayer ad began presenting
    func streamLayerDelegateAdDidStart(_ info: SLRAdInfo) {}

    // Required — resume playback when the ad paused the stream
    func streamLayerDelegateAdDidFinish(_ info: SLRAdInfo) {
        if info.isPausedAd {
            player.play()
        }
    }

    func streamLayerDelegateUpdateDuckingState(_ enabled: Bool) {
        player.volume = enabled ? 0.2 : 1.0
    }
}

Resume button

For .transparentBackground, customize the resume button with SLRResumeButtonConfiguration — content (image, text, or hidden) and position.

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

// Text button at the default position
StreamLayer.showAd(.transparentBackground(url: vastURL, resumeButton: .text("RESUME")))

// Positioned text button
let config = SLRResumeButtonConfiguration(
    content: .text("RESUME"),
    position: .topLeft(insets: UIEdgeInsets(top: 80, left: 80, bottom: 0, right: 0))
)
StreamLayer.showAd(.transparentBackground(url: vastURL, resumeButton: config))

// Hidden button — stays focusable for the remote
StreamLayer.showAd(.transparentBackground(url: vastURL, resumeButton: .hidden))

Available positions are .topLeft, .topRight, .bottomLeft, .bottomRight, .center, and .custom (a closure for full layout control). When you omit the position, the SDK applies .defaultTVOS (60pt left, 64pt from bottom).

Focus: On tvOS the resume button is the default-focused control. The viewer presses the remote to move focus from Resume to the ad's interactive elements (such as a QR code). A .hidden resume button stays in the view hierarchy so it remains reachable with the remote.


Configuration reference

Shared types used across the ad units:

TypePurpose
SLRAdImage.image(UIImage) or .url(URL) — an image source.
SLRAdMediaThe primary image plus an optional autoplaying video (SLRAdVideo).
SLRAdVideourl, plus autoplay (default true), muted (default true), loop (default false).
SLRAdCTARendered as a scannable QR code: url, optional title / subtitle scan-prompt copy.
SLRAdSponsorlogo, name, and layout type (.left, .center, .minimal).
SLRAdTimingduration in seconds and autoclose (default true).
SLRAdBannerBottom banner for the L-bar layout: image and optional tapURL.
SLRResumeButtonConfigurationResume-button content and position for the transparent unit.

Promotion configurations (SLRSidebarPromotionAdConfiguration, SLRPictureInPicturePromotionAdConfiguration, SLRSideBySidePromotionAdConfiguration, SLRSponsorFrameAdConfiguration) take a single background: SLRAdImage?. SLRFullBleedAdConfiguration takes a raw backgroundImage: UIImage? and an optional nested QRCodeConfig(label:url:image:).

SLRSidebarPromotionAdConfiguration additionally accepts isPaused: Bool (default false) — set it to present the sidebar as a paused ad (sidebar-only). The ad-lifecycle callbacks receive an SLRAdInfo with isPausedAd: Bool, telling the host whether the ad paused the stream so it can resume on finish.

public struct SLRAdInfo {
    public let isPausedAd: Bool
}

Platform differences

AspecttvOSiOS
Full-screen unit.fullBleed, all devices.fullScreen, iPad-only
Overlay ad unitNot available.overlay available
Transparent VAST.transparentBackground(url:) accepts a VAST tag directlySeparate .transparentBackgroundVAST case
VAST full-bleed shape.fullBleed shape availableNot available
Call-to-actionRendered as a scannable QR codeTappable button (SLRAdCTA colors)
Background typeSLRAdImage (single image)SLRAdBackground (portrait + landscape)
Ad-finished callbackstreamLayerDelegateAdDidFinish(_ info: SLRAdInfo) (required)streamLayerDelegateAdDidFinish() (parameterless, optional)
Automatic paused-ad deliverysetPausedAdDelivery (Studio / Prebid) — see Paused Ads (tvOS)Not available

See Exposed Ads (iOS) for the iOS-specific units.


Migrating from showPausedAd

The exposed-ads API replaces the earlier showPausedAd "paused ad" API. The old symbols are deprecated but still compile — migrate at your convenience.

DeprecatedReplacement
showPausedAd(_:)showAd(_:)
prefetchPausedAd(_:...)prefetchAd(_:...) / prefetchVASTAd(vastURL:...)
hidePausedAd()hideAd()
clearPrefetchedPausedAds()clearPrefetchedAds()
SLRPausedAdTypeSLRAdType
SLRPausedAdConfiguration / SLRStandardAdFullBleedConfigurationSLRFullBleedAdConfiguration
.transparentBackground(vastTagURL:).transparentBackground(url:)

Related