OptiView Ads SDK

Player-agnostic SDK for Server-Guided Ad Insertion (SGAI) on HLS live streams. The SDK polls a break manifest from the OptiView Ads backend and inserts ad breaks at the specified times — without modifying the content stream.

@dolby-optiview/ads-sdk @dolby-optiview/ads-sdk-core @dolby-optiview/ads-sdk-adapter-hlsjs TypeScript

Architecture

┌─────────────────────────────────────────────────────────────┐ │ Your Application │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ #container (SDK stage, position: relative) │ │ │ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ │ │ │ #playerContainer │ │ .dolby-ad-container │ │ │ │ │ │ (content video UI) │ │ (SDK-managed, hidden │ │ │ │ │ │ │ │ until break starts) │ │ │ │ │ └──────────────────────┘ └──────────────────────┘ │ │ │ └──────────────────────────────────────────────────────┘ │ │ ▲ │ │ │ createAdAdapter factory │ │ ┌────────────────────┐ │ PlayerAdapter interface │ │ │ @dolby-optiview/ads-sdk-core │──────┘ │ │ │ • Manifest poll │ ◄── ContentPlayer (PlayerAdapter) │ │ │ • Break schedule │ │ │ │ • Layout mgmt │ ──► @dolby-optiview/ads-sdk-adapter-hlsjs │ │ └────────────────────┘ (or your own adapter) │ └─────────────────────────────────────────────────────────────┘

Key concepts

  • Internal ad player — the SDK creates its own <video> element and plays ads through it. With @dolby-optiview/ads-sdk the ad player defaults to HLS.js (including iPhone on iOS 17.1+ via Managed Media Source), with a native <video> fallback only for MSE-less platforms such as older iOS (< 17.1)/tvOS, so createAdAdapter is optional; with bare @dolby-optiview/ads-sdk-core you supply a createAdAdapter factory.
  • Two-container DOM — container is the outer SDK stage (anchors the ad overlay and companions); playerContainer wraps your content player UI (scaled to a pip corner for L-shape formats).
  • PlayerAdapter — a thin interface wrapping any HLS-capable player. The SDK never imports player libraries directly.
  • Ad formats — single, double, lshape_ad, lshape_content, overlay. Each format has its own layout rules and content-pause behaviour.
  • Break manifest — a JSON document served by the OptiView Ads backend that schedules when and what ads to play.
  • Session — a per-channel monetization session. Start one per piece of content; end it on channel change or stop.
  • Diagnostics & AI — a structured, redactable diagnostic stream plus IDE-installable AI artifacts (onboarding + adapter skills, onboarding + troubleshooter agents, references) and an optional MCP server that can troubleshoot from a report and bootstrap a runnable demo (scaffold_quickstart), so your own editor's AI can help you get started, integrate, and debug. The MCP server is optional — the shipped *.md artifacts are sufficient on their own.
  • Native too — the same model ships as native Android & iOS/tvOS SDKs (Kotlin/Swift). This page documents the web API; see the platform pages for native getting-started and adapter guides.

Getting Started

The web API reference provides detailed types and method signatures, organized by topic. Start with SDK & Sessions and Configuration. Use Events for player UI notifications and Player Integration for adapter contracts. Break Manifests & Ad Formats, Manifest Customization, and Diagnostics cover manifest data, request hooks and macros, and troubleshooting. On the OptiViewAds class page, members are grouped into Lifecycle, Playback & UI, Events, and Diagnostics.

Fastest way — let your IDE's AI do it. Run npx dolby-ads-init-ai, then ask your editor's AI assistant to follow the Dolby onboarding agent: it explains the model in plain terms and can scaffold a runnable demo for you — no MCP server required. Prefer zero setup? Use the Get started card on the AI Assistance page. The manual steps below are the detailed path if you'd rather wire it up yourself.

Step 1 — Install

npm install @dolby-optiview/ads-sdk hls.js

The @dolby-optiview/ads-sdk entry package defaults the ad player to HLS.js (with a native <video> fallback for MSE-less platforms such as Safari/iOS).

Older Chromium devices

The legacy compatibility target is Chromium 47+ (Tizen 3.x+, webOS 4.x+). webOS 3.x uses Chromium 38 and is not covered. Engine compatibility does not certify codecs, MSE, DRM, IMA, or every player version on those TVs.

The npm SDK is modern ESM. Transpile the complete consumer bundle, including player dependencies and workers, and use a classic-script loader on browsers without ES modules. With Vite, use @vitejs/plugin-legacy targeting Chrome 47; the repository demo provides npm run build:legacy -w @dolby-optiview/ads-sdk-demo. Serve that production output, not the Vite development server.

Keep the existing SDK integration; no loader entry point or SDK polyfill list is needed:

import { OptiViewAds } from '@dolby-optiview/ads-sdk';

const sdk = new OptiViewAds(config);
await sdk.startSession(sessionConfig);

The same automatic preparation applies when importing OptiViewAds from the core package for a custom ad player. Imports and construction remain synchronous, and construction makes no compatibility requests. Inside startSession(), the SDK loads only missing required Object/Promise helpers, URL constructors and abort support before starting analytics or manifest requests. Existing native APIs stay in place; a fully capable runtime takes the normal startup path without an extra compatibility wait. Resize observation uses the SDK's existing local fallback, not an unnecessary downloaded polyfill.

Concurrent instances share preparation. A rejected or stalled compatibility request rejects startSession() (the preparation bound is 15 seconds), and an ended, destroyed or replaced session cannot resume when a chunk arrives later. Promise, ES2015 collections and the platform's DOM/HTTP primitives are part of the Chromium 47 baseline, not replacements for unsupported older engines. The SDK cannot fix unrelated application or player code that runs before it.

Keep code splitting enabled. Inlining dynamic imports downloads all fallback code, even if it is not used. Host every emitted chunk and allow those same-origin scripts through the application's CSP. The SDK does not weaken CSP, TLS or CORS. Its bounded XHR fallback handles missing native request-signal support, including native Request rejecting a replacement signal, while preserving signed manifest bytes. Optional opaque preload requests are skipped on that transport.

Without ResizeObserver, the SDK checks container size every 250 ms and stops on teardown. THEOplayer buffered metadata replay does not require queueMicrotask. For legacy layouts, use explicit container dimensions rather than relying on CSS aspect-ratio. The default ad player uses hls.js for HLS when supported; createAdAdapter can instead select THEOplayer. Validate both content and ad players, rather than assuming the content player's support covers ads.

Step 2 — Add the HTML container

<!-- Include IMA SDK if using Google Ad Manager -->
<script src="https://imasdk.googleapis.com/js/sdkloader/ima3_dai.js"></script>

<!-- Minimal: single container, SDK auto-wraps your content -->
<div id="container" style="position:relative;width:100%;aspect-ratio:16/9">
  <video id="video" controls muted playsinline></video>
  <!-- SDK auto-creates a playerContainer wrapper around the video and appends .dolby-ad-container here -->
</div>

If you need custom controls inside the container, provide an explicit playerContainer:

<div id="container" style="position:relative;width:100%;aspect-ratio:16/9">
  <div id="playerContainer" style="width:100%;height:100%">
    <video id="video" controls muted playsinline></video>
  </div>
</div>

Step 3 — Wire your content player

Create a PlayerAdapter for your content player. With HLS.js:

import Hls from 'hls.js';
import { OptiViewAds, HlsJsAdapter } from '@dolby-optiview/ads-sdk';

const video = document.getElementById('video') as HTMLVideoElement;
const hls = new Hls();
hls.attachMedia(video);

Other supported adapters: @dolby-optiview/ads-sdk-adapter-shaka (Shaka Player), @dolby-optiview/ads-sdk-adapter-theoplayer (THEOplayer), or NativeVideoAdapter (built into @dolby-optiview/ads-sdk for native HLS on Safari/iOS). For a custom player, implement the PlayerAdapter contract — see the dolby-adapter-integration skill.

Step 4 — Create the SDK instance

const sdk = new OptiViewAds({
  player: new HlsJsAdapter(hls, video),
  container: document.getElementById('container'),
  debug: true,
});

With @dolby-optiview/ads-sdk the ad player defaults to HLS.js (including iPhone on iOS 17.1+ via Managed Media Source) and falls back to a native <video> element only where no Media Source engine exists (older iOS < 17.1 / older tvOS). playerContainer is optional — when omitted the SDK automatically wraps the existing children of container.

Step 5 — Subscribe to events

sdk.addEventListener('adbreakbegin', (e) => console.log('Break started:', e.break.id));
sdk.addEventListener('adbreakend', (e) => console.log('Break ended:', e.break.id));

For deeper debugging, subscribe to the diagnostic stream:

sdk.onDiagnostic((e) => console.log(`[${e.level}] ${e.code}`));

Step 6 — Start a monetization session

await sdk.startSession({ manifestUrl: 'https://manifest.example.com/v1/your-org/channels/your-channel' });

Step 7 — Load and play content

hls.loadSource('https://example.com/stream.m3u8');
video.play();

Step 8 — Verify diagnostics

Watch the console for a healthy lifecycle:

  1. DA-SESSION-STARTED — the session is polling the break manifest.
  2. adbreakbegin — an ad break starts (content pauses, ad overlay shows).
  3. adbegin → quartiles → adend — ad creative plays.
  4. adbreakend — break ends, content resumes.

If something goes wrong, capture a full report with sdk.exportDiagnostics() and use the dolby-troubleshooter agent or the AI Assistance page.

Step 9 — Clean up

sdk.endSession(); // stop polling, keep the instance
// or
sdk.destroy(); // full teardown — removes DOM elements, releases resources

Custom ad player with @dolby-optiview/ads-sdk-core

Use the bare @dolby-optiview/ads-sdk-core package when you want to supply your own ad player (a different library, or a pre-configured HLS.js instance). Here createAdAdapter is required — the SDK creates a <div> container and passes it to your factory; create your media element(s) inside it and return a PlayerAdapter.

npm install @dolby-optiview/ads-sdk-core @dolby-optiview/ads-sdk-adapter-hlsjs hls.js
import Hls from 'hls.js';
import { OptiViewAds } from '@dolby-optiview/ads-sdk-core';
import { HlsJsAdapter } from '@dolby-optiview/ads-sdk-adapter-hlsjs';

const video = document.getElementById('video') as HTMLVideoElement;
const hls = new Hls();
hls.attachMedia(video);

const sdk = new OptiViewAds({
  player: new HlsJsAdapter(hls, video),
  container: document.getElementById('container'),
  createAdAdapter: (adContainer) => {
    const adVideo = document.createElement('video');
    adContainer.appendChild(adVideo);
    const adHls = new Hls();
    adHls.attachMedia(adVideo);
    return new HlsJsAdapter(adHls, adVideo);
  },
  debug: true,
});
Bare @dolby-optiview/ads-sdk-core throws if createAdAdapter is omitted (it has no default). Use @dolby-optiview/ads-sdk for the zero-config HLS.js ad player, or supply your own factory.

Reading the SDK version

Read the current SDK version from the static OptiViewAds.version accessor — no instance required. It is the same lockstep version across @dolby-optiview/ads-sdk-core and @dolby-optiview/ads-sdk (and matches the Android/iOS SDKs and the stitcher's GET /version).

import { OptiViewAds } from '@dolby-optiview/ads-sdk'; // or '@dolby-optiview/ads-sdk-core'

console.log(OptiViewAds.version); // e.g. "0.16.0"

Player Adapters

The SDK core (@dolby-optiview/ads-sdk-core) has zero dependencies on any video player library. All player interactions go through the PlayerAdapter interface — a small contract that any player can implement.

This means you can use the SDK with HLS.js today and migrate to Shaka or Video.js later by swapping the adapter, with no changes to your SDK integration code.

The web SDK also uses the optional videoElement reference when it is available. After media metadata arrives, the SDK reads the element's videoWidth and videoHeight to size its internal stage to the content aspect ratio. This keeps percentage-based ad layouts aligned with the painted picture for non-16:9 sources; before valid metadata, it falls back to 16:9. The controller listens for both metadata arrival and intrinsic media-size changes, so rendition and source switches are handled without player-specific code.

React Native host-player bindings are documented separately in React Native support.

Your App └─ OptiViewAds (core) ├─ content PlayerAdapter ──► HlsJsAdapter ──► Hls.js instance └─ createAdAdapter ──► (SDK creates <div>) ──► HlsJsAdapter ──► Hls.js instance ──► ShakaAdapter ──► Shaka Player (future) ──► VideoJsAdapter ──► Video.js (future)

PlayerAdapter Interface

Any adapter must implement all properties and methods below. The interface is imported from @dolby-optiview/ads-sdk-core.

import type {
  PlayerAdapter,
  PlayerAdapterEvent,
  PlayerAdapterEventHandler,
} from '@dolby-optiview/ads-sdk-core';

// Events the adapter must support
type PlayerAdapterEvent = 'timeupdate' | 'ended' | 'error' | 'seeked' | 'volumechange';

interface PlayerAdapter {
  /** Current playback position in seconds. */
  readonly currentTime: number;

  /** Total duration in seconds (Infinity for live streams). */
  readonly duration: number;

  /** True when playback is paused. */
  readonly paused: boolean;

  /** Whether the player is muted. */
  muted: boolean;

  /** Volume level in the range [0, 1]. */
  volume: number;

  /** Current Program Date Time from the HLS manifest.
   *  Required for wallclock-timebase break matching on live streams.
   *  Return null if not available. */
  readonly programDateTime: Date | null;

  /** Pause playback. Called by SDK at break start. */
  pause(): void;

  /** Resume playback. Called by SDK at break end. */
  play(): Promise<void>;

  /** Seek to a position in seconds.
   *  Required for SDK snapback enforcement during locked breaks. */
  seek(time: number): void;

  /** Load a media URL and resolve when ready to play (manifest parsed).
   *  Used for preloading ad content 5 seconds before break start. */
  load(url: string): Promise<void>;

  /** Subscribe to a player event. */
  on(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler): void;

  /** Unsubscribe from a player event. */
  off(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler): void;

  /** Release all resources. Called when SDK is destroyed. */
  destroy(): void;

  /** Best-effort quality constraint used while content continues behind a DAR break.
   *  Pass `null` to restore the exact selection in force before the first set. */
  setVideoQuality?(quality: 'lowest' | null): boolean;

  // ── optional ──────────────────────────────────────────────────────────────

  /** Stop driving the `<video>` without tearing the player down, so the SDK can
   *  play an ad through that same element (shared-element insertion). Resolve only
   *  once the element is genuinely free: an asynchronous teardown that lands after
   *  this resolves will reset the ad engine's MediaSource and the ad never starts.
   *  The SDK hands the element back at break end by calling `load()` again on this
   *  same adapter, so this must NOT destroy the player. Omit it if your engine is
   *  happy to share the element (hls.js is). */
  releaseMediaElement?(): void | Promise<void>;

  /** Whether the content is LIVE, as the engine itself understands it.
   *  Return null when unsure — the SDK falls back to the duration test.
   *  Skip these three live members entirely unless you support live DVR. */
  readonly isLive?: boolean | null;

  /** The furthest position a seek will actually STICK on live content — the
   *  engine pulls back anything closer to the live edge. Return null when
   *  unsure (safe: the SDK then does not clamp); never guess. */
  readonly maxLiveSeekPosition?: number | null;

  /** Where the media currently ENDS, per the ENGINE — only needed when the
   *  <video> element's own seekable range is unreliable (THEOplayer reports
   *  an unbounded sentinel on live). Return null / omit when unsure. */
  readonly seekableEnd?: number | null;

  /** Release the media you hold without destroying the adapter — called on the
   *  AD adapter at break end. May return a promise; when it does the SDK awaits
   *  it before resuming content. */
  unload?(): void | Promise<void>;

  /** Tear down and rebuild the media pipeline (MediaSource / source buffers)
   *  for the CURRENT source, positioned at `startTime` seconds — called on the
   *  CONTENT adapter at break end when `adBreakContentRecovery` resolves to
   *  `recreate` (VIZIO SmartCast). Resolve once metadata for the rebuilt
   *  pipeline is available; reject on failure — the SDK then falls back to
   *  seek + play. Omit when the engine has no such operation. */
  recreateMediaPipeline?(options: { startTime: number }): Promise<void>;
}
Member Notes
programDateTime Critical for live streams with timebase: "wallclock". Track the current EXT-X-PROGRAM-DATE-TIME tag from the HLS manifest. Return null if the stream has no PDT.
load(url) Must resolve only when the player has parsed the manifest and buffered enough to play immediately. Reject on fatal errors. This enables seamless preloading.
seek(time) Set the playback position directly. Used by the SDK to snap back to a break start when controls.snapback: true and a seek is detected.
on / off The SDK listens to 'timeupdate' for break timing, 'ended' to know when an ad asset finishes, 'error' for recovery, 'seeked' for snapback enforcement, and 'volumechange' for mute/volume sync.
releaseMediaElement() (optional) Only called on the shared-element path, where the ad plays through the content player's own <video> — that is how an ad reaches a picture-in-picture window or an OS-native fullscreen, which a DOM overlay cannot. Implement it if your engine will not share the element: Shaka (detach()) and THEOplayer (clearing source) both need it, and without it the two engines fight over the MediaSource and the ad aborts or stalls on its first segment. Finish before you resolve — if your teardown is asynchronous, wait for it. THEOplayer's lands after source = null returns, and it landed on top of the ad engine's freshly-attached MediaSource: the ad manifest was fetched and not one segment was ever requested. Do not tear the player down — the SDK calls load() on the same adapter to give the element back. The SDK also calls this method on the ad adapter your createAdAdapter(container, video) returned for the content element, just before content is reloaded: stop the ad engine there but leave its media on the element, because an element without a resource closes Safari's PiP window.
unload() (optional, ad adapters) The break-end mirror of preload(): in single-decoder preload mode the SDK calls it on the ad adapter when a break ends, before resuming content (in recreate content-recovery mode it is called regardless of preload mode — the ad media must be released before the content pipeline is rebuilt). Release the media you hold (detach your MediaSource / clear the element source) without destroying the adapter — on single-decoder TVs a still-attached ad MediaSource keeps the only hardware decoder and the content resume dies with MEDIA_ERR_DECODE. May return a promise; when it does, the SDK awaits it before resuming. The next break's load() must work like a first load. The default @dolby-optiview/ads-sdk ad player implements it; parallel-preload platforms are never asked.
recreateMediaPipeline(options) (optional, content adapters) Called on the content adapter at break end when adBreakContentRecovery resolves to 'recreate' — on VIZIO SmartCast, content cannot resume in the MediaSource an ad interrupted, so the SDK rebuilds the pipeline at the resume position instead of seeking inside it. Tear down and rebuild the engine's media pipeline for the CURRENT source, seeded at options.startTime seconds; resolve once metadata for the rebuilt pipeline is available and reject on failure — a rejection or a 10 s timeout falls back to seek + play with DA-CONTENT-PIPELINE-RECREATE-FAILED. Do not destroy the adapter and do not call play() — the SDK plays afterwards. The official hls.js, Shaka and THEOplayer adapters implement it; adapters that omit it simply resume in place. An adapter that wraps another adapter must forward it (and unload) — otherwise DA-CONTENT-PIPELINE-RECREATE-FAILED with reason unsupported-adapter is emitted and content resumes in place.
setVideoQuality(quality) (optional) Best-effort quality constraint for continueContentDuringBreak. setVideoQuality('lowest') returns whether an override was applied; setVideoQuality(null) restores the exact prior selection. Official HLS.js, Shaka and THEOplayer adapters implement it. If absent or false, content continues at its current quality and the SDK emits DA-CONTINUE-PLAYOUT-QUALITY-UNAVAILABLE.
isLive (optional) Whether the content is live, per the ENGINE — duration === Infinity is not reliable everywhere (hls.js reports a finite duration for live by default). Gates live-only behaviour such as continued content playout during a break. Return null when unsure; the SDK then falls back to the duration test.
maxLiveSeekPosition (optional) The furthest position a seek will actually stick on live content: every engine enforces a minimum live offset (holdback) and silently pulls back seeks closer to the edge. The SDK clamps its post-break resume and catch-up seeks to this position so they land where intended. Return null when unsure or on VOD — the SDK then does not clamp; never guess a holdback.
seekableEnd (optional) Where the media currently ends, per the ENGINE. Only needed when the <video> element's seekable range is unreliable (THEOplayer reports Number.MAX_SAFE_INTEGER on live). Used for resume reachability and live-edge distance. Omit or return null when the element is trustworthy.

HLS.js Adapter

The official HLS.js adapter is available as a separate package.

Install

npm install @dolby-optiview/ads-sdk-adapter-hlsjs hls.js

Requirements

  • One Hls instance for content. The ad HLS instance is created inside createAdAdapter.
  • One <video> element for content. The ad <video> is created by your factory inside the SDK-provided container.
  • HLS streams must contain EXT-X-PROGRAM-DATE-TIME tags for wallclock-timebase channels.

Full setup example

import Hls from 'hls.js';
import { OptiViewAds } from '@dolby-optiview/ads-sdk-core';
import { HlsJsAdapter } from '@dolby-optiview/ads-sdk-adapter-hlsjs';

const video = document.getElementById('video') as HTMLVideoElement;

const hls = new Hls();
hls.attachMedia(video);

const sdk = new OptiViewAds({
  player: new HlsJsAdapter(hls, video),
  container: document.getElementById('container'),
  createAdAdapter: (adContainer) => {
    const adVideo = document.createElement('video');
    adContainer.appendChild(adVideo);
    const adHls = new Hls();
    adHls.attachMedia(adVideo);
    return new HlsJsAdapter(adHls, adVideo);
  },
  gam: {}, // presence enables GAM; the identity comes from the manifest
});

sdk.addEventListener('adbreakbegin', (e) => {
  console.log('Ad break', e.break.id, 'started —', e.break.duration, 's');
});
sdk.addEventListener('adbreakend', () => {
  console.log('Content resuming');
});

await sdk.startSession({
  manifestUrl: 'https://manifest.example.com/v1/your-org/channels/your-channel',
});

hls.loadSource('https://example.com/live.m3u8');
video.play();

Seek & seeked event

The HLS.js adapter implements seek(time) by setting video.currentTime and forwards the native seeked video event. This enables the SDK to detect and correct unexpected seeks during locked ad breaks.

PDT (Program Date Time)

The HLS.js adapter automatically tracks EXT-X-PROGRAM-DATE-TIME from the manifest and exposes it via programDateTime. This is used by the SDK to match wallclock-timebase breaks against the live stream position.

If your stream does not include EXT-X-PROGRAM-DATE-TIME tags, wallclock breaks cannot be matched. Ensure your origin adds these tags to HLS manifests.

Cleanup

// When the player is torn down:
sdk.destroy(); // also destroys the internal ad player
hls.destroy();

Shaka Player Adapter

The official Shaka Player adapter is available as a separate package. It supports HLS and DASH streams and extracts programDateTime from Shaka's presentation timeline for live streams.

Install

npm install @dolby-optiview/ads-sdk-adapter-shaka shaka-player

Requirements

  • Shaka Player 4.x or 5.x
  • Call shaka.polyfill.installAll() once before creating any shaka.Player instance

Full setup example

import shaka from 'shaka-player';
import { OptiViewAds } from '@dolby-optiview/ads-sdk-core';
import { ShakaAdapter } from '@dolby-optiview/ads-sdk-adapter-shaka';

// 1. Install Shaka polyfills (once per page)
shaka.polyfill.installAll();

// 2. Content player
const video = document.getElementById('video') as HTMLVideoElement;
const player = new shaka.Player(video);

// 3. SDK — pass Player WITHOUT video to avoid eager MediaSource init on the ad element
//    playerContainer is optional; SDK auto-wraps when omitted
const sdk = new OptiViewAds({
  player: new ShakaAdapter(player, video),
  container: document.getElementById('container'),
  createAdAdapter: (adContainer) => {
    const adVideo = document.createElement('video');
    adContainer.appendChild(adVideo);
    return new ShakaAdapter(new shaka.Player(), adVideo);
  },
});

// 5. Start session and load stream
await sdk.startSession({
  manifestUrl: 'https://manifest.example.com/v1/your-org/channels/your-channel',
});
await player.load('https://example.com/live.m3u8');
video.play();

PDT (Program Date Time)

For live HLS streams with EXT-X-PROGRAM-DATE-TIME, the Shaka adapter derives the wall-clock position from Shaka's PresentationTimeline.getInitialProgramDateTime() (the manifest's PDT anchor) combined with video.currentTime. This is used to match wallclock-timebase breaks.

PDT is only reported when the manifest actually carries EXT-X-PROGRAM-DATE-TIME. It returns null for VOD content and for live streams without one — Shaka's synthesized presentation start trails wallclock by the live delay and is deliberately not used, so the SDK falls back to its wallclock clock exactly as with the other adapters.

In-stream timed metadata (ID3 / emsg)

The Shaka adapter forwards in-stream markers to the SDK as timedmetadata cues — used to relay ad-tracking metadata to IMA during SSAI, and to match Anvato in-stream break signaling when ptsSource: 'anvatoCue' is configured (see Configuration).

No Shaka configuration is required. Shaka extracts in-band ID3 from MPEG-TS segments out of the box; you do not need to change streaming/mediaSource settings or register an emsg scheme.

The adapter subscribes to three Shaka events:

Shaka event When it fires Why the adapter uses it
metadataadded at parse/append time, as soon as a segment buffers delivers cues ahead of the playhead — required for Anvato break cues (see below)
metadata when the playhead enters the marker's region covers markers that were already buffered before the adapter attached
emsg when the playhead enters a DASH emsg box DASH/CMAF event messages, e.g. the Anvato scheme urn:anvato:es1:052016

Emsg cues keep their raw payload bytes, so Anvato signaling also works when the marker is a full ID3 GEOB tag wrapped in the standardized ID3-in-emsg carriage (https://aomedia.org/emsg/ID3 / Apple's https://developer.apple.com/streaming/emsg-id3) — the core unwraps the tag itself; the adapter just forwards the bytes.

The adapter emits parsed and presented for each marker. The SDK forwards raw ID3 bytes at parse time, so IMA schedules them by media time, and forwards frames-only markers at presentation time.

Two behaviours are specific to Shaka and handled inside the adapter:

  • GEOB frames are decoded by the adapter, not by Shaka. shaka.util.Id3Utils has no GEOB decoder, so Shaka reports such frames with an empty description, a null MIME type and the whole undecoded frame body as data. The adapter parses that body itself so the cue carries the real description (Anvatos), mimeType (application/json) and payload (type=cue&pts=…). Players that already decode GEOB are passed through untouched.
  • Anvato cues must be seen before their media time. An Anvato cue's media time is the break start, so a playhead-time-only subscription would learn it too late to arm and preload the break. Subscribing to metadataadded gives the adapter the same parse-time delivery HLS.js has.
Shaka exposes no parse-time event for DASH emsg (only a playhead-entry one), so Anvato signaling carried over DASH emsg resolves its cue at the break start rather than ahead of it. Anvato over HLS ID3 — how NFL Channel / NFL Network signal — is unaffected. Tracked as PLAYG-351.

Picture-in-picture on Safari

Safari cannot move a picture-in-picture window to a second <video>, so a break that starts in PiP plays the ad through Shaka's own element: the adapter detaches Shaka for the ad and re-attaches it with load() when the break ends. Keep the SDK's default ad engine for that element (omit createAdAdapter, or return defaultAdAdapter(container, sharedVideo) when the second argument is set). At break end the SDK first tells that ad engine to stand down without emptying the element, then reloads content through Shaka, so the window stays open and content resumes where it left off.

Structural typing — no hard import on shaka-player

The ShakaAdapter constructor accepts any object that satisfies the ShakaPlayerLike interface exported from the package. The real shaka.Player satisfies this interface, but you can also pass a compatible mock in tests without importing the full Shaka library.

Cleanup

// When the player is torn down:
sdk.destroy(); // also destroys the internal ad player
await player.destroy();

THEOplayer Adapter

The official THEOplayer adapter is available as a separate package. Unlike HLS.js and Shaka, THEOplayer manages its own internal <video> element — you pass it a <div> container and it renders into that.

Install

npm install @dolby-optiview/ads-sdk-adapter-theoplayer theoplayer

Requirements

  • THEOplayer v9 or later (peer dependency)
  • A valid THEOplayer license key (passed in the player configuration)
  • Set libraryLocation to serve THEOplayer's worker/WASM files — use a CDN or copy from node_modules/theoplayer/

Key difference — no raw <video> element

THEOplayer creates and manages its own <video> element inside the container div. The adapter exposes it via the videoElement getter (container.querySelector('video')) for GAM integration.

Full setup example

import { ChromelessPlayer } from 'theoplayer/chromeless';
import { OptiViewAds } from '@dolby-optiview/ads-sdk-core';
import { defaultAdAdapter } from '@dolby-optiview/ads-sdk';
import { THEOplayerAdapter } from '@dolby-optiview/ads-sdk-adapter-theoplayer';

const LICENSE = 'YOUR_THEOPLAYER_LICENSE';
const LIB = 'https://cdn.jsdelivr.net/npm/theoplayer@11.4.0/';

// 1. Content player — THEOplayer mounts into a <div>
const playerDiv = document.getElementById('player') as HTMLElement;
const player = new ChromelessPlayer(playerDiv, {
  license: LICENSE,
  libraryLocation: LIB,
  allowMixedContent: true,
  mutedAutoplay: 'all',
});
// 2. SDK — playerContainer is optional; SDK auto-wraps when omitted
const sdk = new OptiViewAds({
  player: new THEOplayerAdapter(player, playerDiv),
  container: document.getElementById('container'),
  createAdAdapter: (adContainer, sharedVideo) => {
    // Shared-element breaks (Safari picture-in-picture, OS-native fullscreen) hand over the
    // content <video>; a ChromelessPlayer cannot adopt an existing element, so let the SDK's
    // own engine play there.
    if (sharedVideo) return defaultAdAdapter(adContainer, sharedVideo);
    const adPlayer = new ChromelessPlayer(adContainer, {
      license: LICENSE,
      libraryLocation: LIB,
      allowMixedContent: true,
      mutedAutoplay: 'all',
    });
    return new THEOplayerAdapter(adPlayer, adContainer);
  },
});

// 3. Start session and load stream
await sdk.startSession({
  manifestUrl: 'https://manifest.example.com/v1/your-org/channels/your-channel',
});
player.source = { sources: [{ src: 'https://example.com/live.m3u8' }] };
player.play();

defaultAdAdapter is exported by @dolby-optiview/ads-sdk.

Picture-in-picture on Safari

Safari does not let the SDK move a picture-in-picture window to a second <video>, so a break that starts in PiP plays the ad through the content player's own element and hands it back to THEOplayer afterwards. Two things are required for the window to survive the break:

  • createAdAdapter must honour the sharedVideo argument as shown above. A ChromelessPlayer created for the handed-over element fails inside THEOplayer's IMA module (AdError 1101) and the break never starts.
  • A THEOplayer build that keeps the media element's resource attached across a source change while the element is in PiP. THEOplayer Web SDK 11.12.1 and earlier empty the element on every source change, and WebKit closes the window the moment an element has no media resource — the SDK cannot prevent that from outside the player. A player-side change is in progress with the THEOplayer team.

Expect one blank frame when content resumes: the browser resets the decoder when THEOplayer attaches the content MediaSource over the ad's. Setting preload: 'auto' (or 'metadata') on the content player shortens the gap.

Autoplay configuration

The ad player's play() is triggered by the SDK's break scheduler — always outside a user gesture. Set mutedAutoplay: 'all' in the ChromelessPlayer config for both the content and ad player instances. This tells THEOplayer to permit autoplay regardless of browser policy.

Omitting mutedAutoplay: 'all' will cause the ad player to silently refuse to play on browsers with strict autoplay policies (Chrome, Safari, most Smart TV WebViews).

PDT (Program Date Time)

The THEOplayer adapter reads player.currentProgramDateTime directly — THEOplayer exposes the wall-clock position as a Date on that property for live HLS/DASH streams. No custom manifest parsing is needed.

Cleanup

sdk.destroy(); // also destroys the internal ad player
player.destroy();

Native / Safari & iOS (NativeVideoAdapter)

@dolby-optiview/ads-sdk ships a NativeVideoAdapter backed by a plain HTMLVideoElement. The default ad player (a RoutingAdAdapter) chooses its playback technology from the ad creative, not from Hls.isSupported() alone — so it uses NativeVideoAdapter in two cases:

  • a progressive STATIC creative (MP4/WebM/…) — routed to native <video> even on MSE browsers where HLS.js is available, because HLS.js can only parse HLS playlists (feeding it an MP4 fails with manifestParsingError);
  • any creative on platforms without a Media Source engine (Hls.isSupported() === false), notably iPhone/iPod on iOS < 17.1 and older iOS/tvOS WebViews, which play HLS natively via video.src.

An HLS playlist creative (.m3u8 / application/vnd.apple.mpegurl) goes through the HlsJsAdapter when HLS.js is supported. The technology is decided at load() time from the URL extension, with a Content-Type HEAD probe for extensionless URLs (e.g. GAM pod manifests, which stay on HLS.js); anything that can't be classified defaults to HLS.js.

iOS 17.1+ uses HLS.js for HLS creatives. Apple's Managed Media Source (MMS) is available on iPhone from iOS 17.1, so Hls.isSupported() returns true and HLS playlist creatives play through the HlsJsAdapter (full ABR, quality/track selection, precise buffering); progressive MP4 creatives still use NativeVideoAdapter. On older iOS and other MSE-less runtimes everything falls back to NativeVideoAdapter.

// Inside @dolby-optiview/ads-sdk's default ad adapter, per creative at load() time:
// The Content-Type probe is bounded at 5s; a host that never answers falls back to 'hls'.
const tech = selectAdTech(url); // 'native' | 'hls' (extension, then Content-Type)
if (tech === 'hls' && Hls.isSupported()) {
  // → HlsJsAdapter (MSE) for HLS playlists
} else {
  // → NativeVideoAdapter (progressive MP4/WebM, or native HLS fallback)
}

You normally never construct it directly — use @dolby-optiview/ads-sdk and it is selected for you. Import it explicitly only if you build a custom factory:

import { NativeVideoAdapter } from '@dolby-optiview/ads-sdk';

Demo content player (PLAYG-36). The same NativeVideoAdapter also backs the demo's "Native HLS" content player option. The demo auto-selects it for the content <video> whenever Hls.isSupported() is false (the MSE-less tail: iPhone Safari < 17.1, older iOS/tvOS WebViews) so content still plays via video.src; on MSE/MMS-capable runtimes hls.js stays the default. You can also force it via the ?player=native URL param to exercise the native path on an MSE-capable browser such as macOS Safari.

PDT (Program Date Time)

Wallclock break matching is supported on native HLS: NativeVideoAdapter derives programDateTime from WebKit's non-standard HTMLVideoElement.getStartDate() (the presentation origin's wallclock) plus currentTime, mirroring how the HLS.js adapter computes it. It returns null only on engines without getStartDate() or when the date is invalid — in which case wallclock matching is unavailable for that stream.

iPhone / iPod: adaptive & shared-element insertion

On iPhone/iPod, adInsertion: 'auto' (the default) resolves based on Managed Media Source availability:

  • iOS 17.1+ (MMS present) → adaptive. Playback runs through HLS.js/MMS, so the SDK uses the full overlay compositor (all break formats) while the content video is inline, and falls back to a single fullscreen shared-element ad only while the content video is in OS-native fullscreen (where DOM overlays cannot render). The mode is re-evaluated per break from the content video's webkitDisplayingFullscreen state / webkitbeginfullscreen/webkitendfullscreen events.
  • iOS < 17.1 (no MMS) → shared-element. The ad plays through the content <video> rather than a separate ad element; advanced formats are downgraded to a single fullscreen ad and lshape_content is skipped.

See SDK Configuration → Ad insertion behaviour.

AirPlay note: MMS sets disableRemotePlayback=true, disabling AirPlay on the SDK-owned ad element (ads are not AirPlayed). If you need AirPlay on your content player, append an HLS <source> element to your own content <video>.

Implementing a Custom Adapter

To use the SDK with Video.js, AVPlayer (iOS), or any other HLS-capable player, implement the PlayerAdapter interface.

TypeScript skeleton

import type {
  PlayerAdapter,
  PlayerAdapterEvent,
  PlayerAdapterEventHandler,
} from '@dolby-optiview/ads-sdk-core';

export class MyPlayerAdapter implements PlayerAdapter {
  private player: MyPlayer; // ← your player instance
  private video: HTMLVideoElement;
  private listeners = new Map<PlayerAdapterEvent, Set<PlayerAdapterEventHandler>>();
  private _programDateTime: Date | null = null;

  constructor(player: MyPlayer, videoElement: HTMLVideoElement) {
    this.player = player;
    this.video = videoElement;
    this.setupListeners();
  }

  // ── Required properties ──────────────────

  get currentTime() {
    return this.video.currentTime;
  }
  get duration() {
    return this.video.duration;
  }
  get paused() {
    return this.video.paused;
  }
  get muted() {
    return this.video.muted;
  }
  set muted(v) {
    this.video.muted = v;
  }
  get volume() {
    return this.video.volume;
  }
  set volume(v) {
    this.video.volume = v;
  }

  get programDateTime(): Date | null {
    // Track EXT-X-PROGRAM-DATE-TIME from your player's manifest events
    return this._programDateTime;
  }

  // ── Required methods ─────────────────────

  pause(): void {
    this.video.pause();
  }

  async play(): Promise<void> {
    await this.video.play();
  }

  seek(time: number): void {
    this.video.currentTime = time; // adapt to your player's seek API if needed
  }

  /** Load URL — resolve when manifest is parsed and player is ready to play. */
  async load(url: string): Promise<void> {
    return new Promise((resolve, reject) => {
      this.player.once('manifestparsed', resolve); // adapt to your player's event
      this.player.once('error', reject);
      this.player.load(url);
    });
  }

  on(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler): void {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(handler);
  }

  off(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler): void {
    this.listeners.get(event)?.delete(handler);
  }

  destroy(): void {
    this.listeners.clear();
    // Remove any event listeners added in setupListeners()
  }

  // ── Internal helpers ─────────────────────

  private emit(event: PlayerAdapterEvent, data?: unknown): void {
    this.listeners.get(event)?.forEach((h) => h(data));
  }

  private setupListeners(): void {
    // Forward native video events to SDK listeners
    this.video.addEventListener('timeupdate', () => this.emit('timeupdate'));
    this.video.addEventListener('ended', () => this.emit('ended'));
    this.video.addEventListener('error', () => this.emit('error'));
    this.video.addEventListener('seeked', () => this.emit('seeked'));
    this.video.addEventListener('volumechange', () => this.emit('volumechange'));

    // Track PDT from your player's manifest events
    this.player.on('fragmentchanged', (frag) => {
      if (frag.programDateTime) this._programDateTime = new Date(frag.programDateTime);
    });
  }
}
Key implementation notes:
• load(url) must resolve only when the player is ready to start playback immediately — not just when the request is sent.
• programDateTime must reflect the current stream position, not just the start of the manifest.
• The SDK passes a <div> container to createAdAdapter. Create your own <video> inside it — never share the content player instance or its video element.

Validate your adapter

@dolby-optiview/ads-sdk-adapter-test-kit provides a shared conformance suite so you can prove your adapter satisfies the contract the SDK relies on (event forwarding, state mirroring, seek, destroy cleanup, and optional capabilities). The official HLS.js and Shaka adapters run the same kit.

# not on npmjs: install the tarball from the artefact host (see Install → Web)
npm install -D <artefact host>/web/<channel>/<version>/dolby-ads-adapter-test-kit-<version>.tgz
// MyPlayerAdapter.conformance.test.ts
import { runAdapterConformance } from '@dolby-optiview/ads-sdk-adapter-test-kit';
import { MyPlayerAdapter } from './MyPlayerAdapter';

runAdapterConformance('MyPlayerAdapter', {
  // Construct your adapter around the kit-provided <video> element.
  createAdapter: (video) => new MyPlayerAdapter(createMyPlayer(), video),
  // Declare which optional members you implement so the matching checks run.
  capabilities: { videoElement: true, preload: false, parallelBuffering: false },
});

The kit registers its own describe/it blocks, so just call runAdapterConformance(...) at the top level of a test file. By default it makes the underlying source emit each event by dispatching a native Event on the video element; pass a custom emit(video, event) if your player surfaces events differently.

Android (Kotlin)

The OptiView Ads SDK ships a native Android / Android TV SDK in Kotlin. It implements the same Server-Guided Ad Insertion model as the web SDK — manifest polling, break scheduling on the programDateTime timebase, the ad-event lifecycle, GAM/DAI pod serving, and the redactable diagnostics stream — built on Media3 / ExoPlayer.

Like the web SDK, the core is player-agnostic: it drives your content player only through the PlayerAdapter interface, so you can use the bundled Media3 adapter or implement your own.

The break-cut countdown starts on actual ad playback, not media loading or STATE_READY. The full effective duration plus adBreakCutSafetyMarginSec is then available. Buffering does not restart it; startup has an independent fail-open timeout. React Native Android uses the same renderer and timing.

Getting Started

Requirements

  • Android minSdk 21 (Android TV supported), compileSdk 34
  • Media3 / ExoPlayer 1.4.x
  • For Google Ad Manager pod serving: the IMA SDK (com.google.ads.interactivemedia.v3:interactivemedia)

Installation

Stable releases use the public THEOplayer Maven repository. Add it to settings.gradle.kts, retaining Google and Maven Central for third-party libraries:

dependencyResolutionManagement {
    repositories {
        maven { url = uri("https://maven.theoplayer.com/releases") }
        google()
        mavenCentral()
    }
}

For a beta or other tagged prerelease, also add maven { url = uri("https://maven.theoplayer.com/snapshots") } and pin the exact version, such as 1.0.0-beta.1. Keep /releases for stable dependencies; do not use snapshotsOnly(), because prerelease versions retain their original suffix. Develop/custom builds still use the artifact-host flow in Install / Distribution. The app also needs JDK 17 and android.useAndroidX=true.

Lens observability dependency. ads-sdk reports one Lens session per Ads session — always on, no configuration. Published artifacts bundle Lens, so consumers need no private Maven credentials or separate Lens dependency. Building the SDK from source still needs private read access; see Install / Distribution.
// build.gradle.kts (app module) — published coordinates
dependencies {
    implementation("com.dolby.optiview:ads-sdk-runtime:<version>")  // ExoPlayerAdapter + OverlayAdRenderer + GAM/IMA
    // ads-sdk-runtime re-exports the core + sdk layers transitively.

    implementation("androidx.media3:media3-exoplayer:1.4.1")
    implementation("androidx.media3:media3-ui:1.4.1")
}

Quick start

import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.dolby.optiview.ads.runtime.ExoPlayerAdapter
import com.dolby.optiview.ads.runtime.OverlayAdRenderer
import com.dolby.optiview.ads.sdk.CoroutineSchedulerTicker
import com.dolby.optiview.ads.sdk.OptiViewAds
import com.dolby.optiview.ads.sdk.OptiViewAdsConfig
import com.dolby.optiview.ads.sdk.OptiViewAdsEventType
import com.dolby.optiview.ads.sdk.HttpManifestSource
import com.dolby.optiview.ads.sdk.SessionConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

// 1. Content player — you own the ExoPlayer and attach it to your PlayerView.
val contentPlayer = ExoPlayer.Builder(context).build()
playerView.player = contentPlayer
val contentAdapter = ExoPlayerAdapter(contentPlayer)

// 2. Runtime seams: overlay ad renderer + HTTP manifest source + coroutine ticker.
//    `overlayContainer` is a FrameLayout stacked above your PlayerView.
val renderer       = OverlayAdRenderer(context, overlayContainer, contentAdapter)
val manifestSource = HttpManifestSource(scope)
val ticker         = CoroutineSchedulerTicker(scope)

// 3. Create the SDK.
val sdk = OptiViewAds(
    config = OptiViewAdsConfig(player = contentAdapter, debug = true),
    renderer = renderer,
    manifestSource = manifestSource,
    ticker = ticker,
    scope = scope,
)

// 4. Listen to events.
sdk.addEventListener(OptiViewAdsEventType.ADBREAKBEGIN) { e -> Log.d("Ads", "Break started: ${e.breakId}") }
sdk.addEventListener(OptiViewAdsEventType.ADBREAKEND)   { e -> Log.d("Ads", "Break ended: ${e.breakId}") }
sdk.addEventListener(OptiViewAdsEventType.ADCHANNELCHANGE) { e ->
    Log.d("Ads", "Channel changed: ${e.previousChannelId} -> ${e.channelId} (${e.previousDeliveryMode} -> ${e.deliveryMode})")
}

// 5. Load your content stream and start a monetization session.
contentPlayer.setMediaItem(MediaItem.fromUri("https://example.com/live.m3u8"))
contentPlayer.prepare()
contentPlayer.playWhenReady = true

scope.launch {
    sdk.startSession(SessionConfig(manifestUrl = "https://manifest.example.com/v1/your-org/channels/your-channel"))
}

During an active ad break, the event is emitted after that break finishes and the latest polled manifest is applied.

Every published Android SDK version uses the same embedded manifest trust list: ads-prod-2026-08, ads-staging-2026-08, and ads-dev-2026-08. HttpManifestSource(scope) accepts an optional ManifestFetcher for transport integration, but its public constructor cannot accept custom trusted keys. The repository test key is available only to unit/conformance tests through an internal testability seam; CI does not select Android keys by branch or version.

All SDK calls run on the player's application thread — typically the main thread, per ExoPlayer's threading model. startSession is a suspend function: call it from a coroutine. Call sdk.destroy() (then release your ExoPlayer) when tearing down.

Ad formats & overlay rendering

Native ad creatives default to AdScaling.FIT (aspect-fit/contain), matching the web renderer's primary ad surfaces. Pass adScaling = AdScaling.FILL to opt into aspect-fill cropping. Companion/backdrop artwork remains cover-scaled independently, and fullscreen ad surfaces follow the visible content picture rectangle when the adapter reports intrinsic video dimensions.

OverlayAdRenderer renders every break format. For the overlay format it positions/sizes the ad surface from the manifest position/size/opacity and does not pause the content player (the ad plays on top of playing content); all other formats render full-surface and pause content. Overlay assets with mediaType: "image" are rendered in a ImageView (held for the asset's duration, falling back to the break duration), are preloaded for an instant transition, and on load failure dispatch aderror and are ignored — identical to the web and iOS runtimes. See Ad Formats.

SGAI GAM and VAST controls share the ad video's visibility and disappear when its presentation ends, including after errors or a forced break cutoff. No application-side IMA view cleanup is required. Stitched SSAI controls remain separate from the SDK's ad video surface.

Reading the SDK version

Read the current SDK version from the static OptiViewAds.version accessor — no instance required. It is the same lockstep version as the web and iOS SDKs and the stitcher's GET /version.

import com.dolby.optiview.ads.sdk.OptiViewAds

Log.d("Ads", OptiViewAds.version) // e.g. "0.16.0"

Google Ad Manager (pod serving)

Supply a GamConfig to the renderer and to OptiViewAdsConfig. Its presence is the opt-in — the stream identity comes from the break manifest's vendorConfiguration. GAM vendor breaks are served via Google IMA DAI.

import com.dolby.optiview.ads.sdk.GamConfig

val gam = GamConfig()
val renderer = OverlayAdRenderer(context, overlayContainer, contentAdapter, gamConfig = gam)

val sdk = OptiViewAds(
    config = OptiViewAdsConfig(player = contentAdapter, gam = gam),
    renderer = renderer,
    manifestSource = manifestSource,
    ticker = ticker,
    scope = scope,
)

scope.launch {
    sdk.startSession(SessionConfig(manifestUrl = "https://manifest.example.com/v1/your-org/channels/your-channel"))
}
GAM assets are skipped, with DA-GAM-CONFIG-MISSING, if the break manifest carries no usable gam entry in vendorConfiguration. That is a backend/provisioning fix — the identity is deliberately not configurable here.

Break transitions (transition)

How the picture changes at the edges of a break. The ad surface is held back until the ad has actually rendered its first frame, then cross-fades in over the paused content frame; at the end it dissolves away over resumed content. Nothing is shown while it is still blank, which is what removes the black flash at a fullscreen (single) break.

OptiViewAdsConfig(
    player = contentAdapter,
    transition = TransitionConfig(),                              // fade, 300ms (default)
    // transition = TransitionConfig(type = TransitionType.NONE), // cut, no animation
    // transition = TransitionConfig(durationMs = 500),           // slower fade
)
Field Type Default Meaning
type FADE / NONE FADE NONE cuts, which is the behaviour before this option.
durationMs Long 300 Matches the web core's TRANSITION_MS. Ignored for NONE.
The choice also decides the ad surface type, because a fade cannot be drawn on the default one: a SurfaceView's video is composited in its own punch-through layer, so View.alpha never blends the picture — the ad appears in one step no matter how long the animation. FADE therefore renders ads into a TextureView, and NONE keeps the SurfaceView. Choose NONE if your ad creatives are DRM-protected: a TextureView cannot present secure output. It also costs somewhat more memory and power. The CONTENT surface is never touched — this applies only to the SDK's own ad view.

An ad that renders no frame at all — an audio-only creative, or a decoder that never answers — is revealed anyway once the ad-start deadline passes, so it can never play invisibly. Every reveal emits DA-AD-SURFACE-REVEALED with reason (first-frame vs reveal-timeout) and waitedMs, the wait that would otherwise have been a black gap.

For a format that covers content (single, lshape_ad), content keeps playing under the fade and is paused as it lands, so the ad dissolves over moving video rather than over a still frame. Its audio is muted from the start of the break (and restored at the end, preserving a viewer's own mute), because two pictures may overlap during a cross-fade but two soundtracks may not. Under adPreload = single-decoder the pause stays immediate: that mode exists to avoid holding two decoders, which is exactly what playing content through the ad's load would do. Either way the content resume at break end is unchanged.

Existing Adapter — ExoPlayerAdapter

The runtime ships ExoPlayerAdapter, a Media3/ExoPlayer implementation of PlayerAdapter. It wraps an ExoPlayer instance you create and own (attached to your own PlayerView); the SDK drives the content player only through this adapter, keeping the brain player-agnostic.

import com.dolby.optiview.ads.runtime.ExoPlayerAdapter

val contentPlayer = ExoPlayer.Builder(context).build()
val contentAdapter = ExoPlayerAdapter(contentPlayer)
// → pass as OptiViewAdsConfig.player (and to OverlayAdRenderer).
Concern Behaviour
Threading All calls must run on the player's application thread (typically the main thread). A single Player.Listener fans Media3 callbacks out to the adapter's event handlers.
programDateTime Derived from the live window's windowStartTimeMs plus the current position. Returns null for VOD / streams without a wallclock anchor, in which case wallclock-timebase breaks cannot be matched.
seek / seeked seek(time) calls player.seekTo; the seeked event fires on a seek position discontinuity, enabling snapback enforcement during locked breaks.
load setMediaItem + prepare — kicks off preparation and returns immediately; readiness is signalled via playing / waiting / ended events.

THEOplayer Adapter — THEOplayerAdapter

The ads-sdk-adapter-theoplayer artifact wraps a com.theoplayer.android.api.player.Player. THEOplayer is compileOnly in the adapter, so the host app provides its own com.theoplayer.theoplayer-sdk-android:core dependency.

// app/build.gradle.kts
implementation("com.dolby.optiview:ads-sdk-adapter-theoplayer:<version>")
implementation("com.theoplayer.theoplayer-sdk-android:core:<version>")
import com.dolby.optiview.ads.adapter.theoplayer.THEOplayerAdapter
import com.theoplayer.android.api.player.Player

val contentAdapter = THEOplayerAdapter(player)

PlayerAdapter Interface

Any content player is integrated by implementing PlayerAdapter from com.dolby.optiview.ads.core. Unlike the web adapter (whose load/play return Promises), the Kotlin load/play are synchronous — they kick off preparation/playback and return immediately; readiness and completion are signalled via PlayerAdapterEvents.

package com.dolby.optiview.ads.core

/** Event types a PlayerAdapter must forward. */
enum class PlayerAdapterEvent(val value: String) {
    TIMEUPDATE("timeupdate"),
    ENDED("ended"),
    ERROR("error"),
    SEEKED("seeked"),
    VOLUMECHANGE("volumechange"),
    WAITING("waiting"),
    PLAYING("playing"),
}

/** Handler payload carries adapter-specific detail (e.g. a media error). */
typealias PlayerAdapterEventHandler = (event: Any?) -> Unit

interface PlayerAdapter {
    /** Current playback time in seconds. */
    val currentTime: Double

    /** Total content duration in seconds; Double.POSITIVE_INFINITY for live. */
    val duration: Double

    /** Whether the player is currently paused. */
    val paused: Boolean

    /** Muted state. Settable so the SDK can sync content↔ad mute. */
    var muted: Boolean

    /** Volume in [0, 1]. Settable so the SDK can sync content↔ad volume. */
    var volume: Double

    /** Program Date Time (EXT-X-PROGRAM-DATE-TIME) as epoch milliseconds,
     *  for wallclock-timebase break matching. Null when unavailable. */
    val programDateTime: Long?

    /** Pause playback (called when an ad break starts). */
    fun pause()

    /** Resume playback (called when an ad break ends). Returns immediately. */
    fun play()

    /** Seek to time seconds (used for snapback during locked breaks). */
    fun seek(time: Double)
    fun seekExact(time: Double) // SDK-owned post-break resume positioning

    /** Load a media source without committing to immediate playback. */
    fun load(url: String)

    /** Optional: warm caches WITHOUT engaging a decoder (single-decoder preload). */
    fun preload(url: String) {}

    /** Optional capability hint: can this adapter buffer a second source in
     *  parallel without decoder contention? Null is treated as true. */
    val supportsParallelBuffering: Boolean?
        get() = null

    /** Subscribe to a player event. */
    fun on(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler)

    /** Unsubscribe a previously registered handler. */
    fun off(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler)

    /** Clean up resources (called when the SDK is destroyed). */
    fun destroy()
}
Member Notes
programDateTime Epoch milliseconds (Long?), not a Date — keeps the brain dependency-free. Critical for live streams with timebase: "wallclock"; return null if the stream has no PDT.
load(url) Kicks off preparation and returns immediately. Signal readiness via the playing/waiting events rather than blocking.
seek(time) Used by the SDK to snap back to a break start when controls.snapback is set and an unexpected seek is detected.
on / off The SDK listens to timeupdate for break timing, ended for asset completion, error for recovery, seeked for snapback, and volumechange / waiting / playing for state sync.
preload / supportsParallelBuffering Optional. Override preload only if your player supports a detached cache warm; leave the defaults otherwise.

Implementing a Custom Adapter

To use the SDK with a different player, implement PlayerAdapter and forward your player's callbacks to the SDK event handlers.

import com.dolby.optiview.ads.core.PlayerAdapter
import com.dolby.optiview.ads.core.PlayerAdapterEvent
import com.dolby.optiview.ads.core.PlayerAdapterEventHandler

class MyPlayerAdapter(private val player: MyPlayer) : PlayerAdapter {

    private val handlers = mutableMapOf<PlayerAdapterEvent, MutableSet<PlayerAdapterEventHandler>>()

    init {
        // Forward your player's callbacks to the SDK.
        player.onPositionChanged { dispatch(PlayerAdapterEvent.TIMEUPDATE) }
        player.onEnded          { dispatch(PlayerAdapterEvent.ENDED) }
        player.onError          { err -> dispatch(PlayerAdapterEvent.ERROR, err) }
        player.onSeeked         { dispatch(PlayerAdapterEvent.SEEKED) }
        player.onVolumeChanged  { dispatch(PlayerAdapterEvent.VOLUMECHANGE) }
    }

    // ── Required properties ──────────────────
    override val currentTime: Double get() = player.positionMs / 1000.0
    override val duration: Double get() = if (player.isLive) Double.POSITIVE_INFINITY else player.durationMs / 1000.0
    override val paused: Boolean get() = !player.isPlaying

    override var muted: Boolean
        get() = player.volume == 0f
        set(value) { player.volume = if (value) 0f else 1f }

    override var volume: Double
        get() = player.volume.toDouble()
        set(value) { player.volume = value.coerceIn(0.0, 1.0).toFloat() }

    override val programDateTime: Long?
        get() = player.currentProgramDateEpochMs   // null for VOD / no PDT

    // ── Required methods ─────────────────────
    override fun pause() = player.pause()
    override fun play()  = player.play()
    override fun seek(time: Double) { player.seekTo((time * 1000).toLong()) }
    override fun load(url: String)  { player.setSource(url); player.prepare() }

    override fun on(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler) {
        handlers.getOrPut(event) { mutableSetOf() }.add(handler)
    }

    override fun off(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler) {
        handlers[event]?.remove(handler)
    }

    override fun destroy() {
        handlers.clear()
        // Remove any listeners registered on `player`.
    }

    private fun dispatch(event: PlayerAdapterEvent, payload: Any? = null) {
        handlers[event]?.toList()?.forEach { it(payload) }
    }
}
Key implementation notes:
• load(url) and play() return immediately — signal readiness through playing/waiting events, never block.
• programDateTime must reflect the current stream position (epoch ms), not just the start of the manifest.
• Never share the content player instance with the ad renderer — the SDK owns its own ad player surface.

Validate your adapter

Add the conformance kit to the test source set:

testImplementation("com.dolby.optiview:ads-sdk-adapter-test-kit:<version>")

Extend PlayerAdapterConformanceTest and override createAdapter():

import com.dolby.optiview.ads.core.PlayerAdapter
import com.dolby.optiview.ads.testkit.PlayerAdapterConformanceTest

class MyPlayerAdapterTest : PlayerAdapterConformanceTest() {
    override fun createAdapter(): PlayerAdapter = MyPlayerAdapter(player)
}

iOS / tvOS (Swift)

The OptiView Ads SDK ships a native iOS / tvOS SDK in Swift. It implements the same Server-Guided Ad Insertion model as the web SDK — manifest polling, break scheduling on the programDateTime timebase, the ad-event lifecycle, GAM/DAI pod serving, and the redactable diagnostics stream — built on AVFoundation / AVPlayer.

Like the web SDK, the core is player-agnostic: it drives your content player only through the PlayerAdapter protocol, so you can use the bundled AVPlayer adapter or implement your own. A single import OptiViewAdsRuntime re-exports the core + SDK layers.

The break-cut countdown starts on actual ad playback, not loading or AVPlayerItem readiness. The full effective duration plus adBreakCutSafetyMarginSec is then available. Buffering does not restart it; startup has an independent fail-open timeout. React Native iOS/tvOS forwards its configured margin to this same renderer.

Getting Started

Requirements

  • iOS / tvOS 15+ (the runtime depends on the Google IMA SDK, which requires 15+)
  • AVFoundation / AVPlayer
  • For Google Ad Manager pod serving: GoogleInteractiveMediaAds — Google ships it as two platform-specific SPM packages exporting the same module (…-google-interactive-media-ads-ios for iOS, …-google-interactive-media-ads-tvos for tvOS); add the one(s) matching your app's platforms

Installation

The xcframeworks are published per release; take each url and checksum from that release's manifest.json — see Install / Distribution.

// Package.swift — Swift Package Manager
dependencies: [
    .package(url: "https://github.com/googleads/swift-package-manager-google-interactive-media-ads-ios", from: "3.18.4"),
    .package(url: "https://github.com/googleads/swift-package-manager-google-interactive-media-ads-tvos", from: "4.2.0"),
],
targets: [
    .binaryTarget(name: "OptiViewAdsCore",
        url: "<release base URL>/OptiViewAdsCore.xcframework.zip",
        checksum: "<see manifest.json>"),
    .binaryTarget(name: "OptiViewAdsSDK",
        url: "<release base URL>/OptiViewAdsSDK.xcframework.zip",
        checksum: "<see manifest.json>"),
    .binaryTarget(name: "OptiViewAdsRuntime",
        url: "<release base URL>/OptiViewAdsRuntime.xcframework.zip",
        checksum: "<see manifest.json>"),
    .target(name: "MyApp", dependencies: [
        "OptiViewAdsRuntime", "OptiViewAdsSDK", "OptiViewAdsCore",
        .product(name: "GoogleInteractiveMediaAds", package: "swift-package-manager-google-interactive-media-ads-ios",
                 condition: .when(platforms: [.iOS])),
        .product(name: "GoogleInteractiveMediaAdsTvOS", package: "swift-package-manager-google-interactive-media-ads-tvos",
                 condition: .when(platforms: [.tvOS])),
    ]),
]
# Podfile — CocoaPods
source 'https://github.com/THEOplayer/cocoapods-specs.git'
source 'https://cdn.cocoapods.org/'

pod 'OptiViewAdsRuntime', '<version>'
Lens observability. Published binaries bundle Lens; no separate Lens dependency is needed.

Quick start

import AVFoundation
import OptiViewAdsRuntime   // re-exports OptiViewAdsCore + OptiViewAdsSDK

// 1. Content player — you own the AVPlayer and render it (AVPlayerLayer / AVPlayerViewController).
let contentPlayer = AVPlayer()
playerLayer.player = contentPlayer
let contentAdapter = AVPlayerAdapter(player: contentPlayer)

// 2. Runtime seams: overlay ad renderer + Foundation manifest source + dispatch ticker.
//    `overlayContainer` is a UIView stacked above your content surface.
let renderer = OverlayAdRenderer(overlayContainer: overlayContainer, contentPlayer: contentAdapter)

// 3. Create the SDK.
let sdk = OptiViewAds(
    config: OptiViewAdsConfig(player: contentAdapter, debug: true),
    renderer: renderer,
    manifestSource: URLSessionManifestSource(),
    ticker: DispatchSchedulerTicker()
)

// 4. Listen to events (addEventListener returns a token for removeEventListener).
sdk.addEventListener(.adbreakbegin) { event in print("Break started:", event.breakId ?? "") }
sdk.addEventListener(.adbreakend)   { event in print("Break ended:", event.breakId ?? "") }

// 5. Load your content stream and start a monetization session.
if let url = URL(string: "https://example.com/live.m3u8") {
    contentPlayer.replaceCurrentItem(with: AVPlayerItem(url: url))
    contentPlayer.play()
}

Task {
    do {
        try await sdk.startSession(SessionConfig(manifestUrl: "https://manifest.example.com/v1/your-org/channels/your-channel"))
    } catch {
        print("startSession failed:", error)
    }
}

Every published iOS/tvOS SDK version uses the same embedded manifest trust list: ads-prod-2026-08, ads-staging-2026-08, and ads-dev-2026-08. URLSessionManifestSource(session:) accepts an optional URLSession for transport integration, but its public initializer cannot accept custom trusted keys. The repository test key is available only to unit/conformance tests through an internal testability seam; neither DEBUG builds nor CI change the Apple key list.

startSession is async throws — call it from a Task. UIKit/AVPlayer work is marshalled to the main actor, so construct the SDK and renderer on the main thread. Call sdk.destroy() when tearing down.

Ad formats & overlay rendering

Native ad creatives default to .fit (aspect-fit/contain), matching the web renderer's primary ad surfaces. Pass adScaling: .fill to opt into aspect-fill cropping. Companion/backdrop artwork remains cover-scaled independently, and fullscreen ad surfaces follow stageRect when the content picture geometry is available.

OverlayAdRenderer renders every break format. For the overlay format it positions/sizes the ad surface from the manifest position/size/opacity and does not pause the content player (the ad plays on top of playing content); all other formats render full-surface and pause content. Overlay assets with mediaType: "image" are rendered in a UIImageView (held for the asset's duration, falling back to the break duration), are preloaded for an instant transition, and on load failure dispatch aderror and are ignored — identical to the web and Android runtimes. See Ad Formats.

Reading the SDK version

Read the current SDK version from the static OptiViewAds.version property — no instance required. It is the same lockstep version as the web and Android SDKs and the stitcher's GET /version.

print(OptiViewAds.version) // e.g. "0.16.0"

Google Ad Manager (pod serving)

Supply a GamConfig to the renderer and to OptiViewAdsConfig. Its presence is the opt-in — the stream identity comes from the break manifest's vendorConfiguration. GAM vendor breaks are served via Google IMA DAI.

let gam = GamConfig()
let renderer = OverlayAdRenderer(overlayContainer: overlayContainer, contentPlayer: contentAdapter, gamConfig: gam)

let sdk = OptiViewAds(
    config: OptiViewAdsConfig(player: contentAdapter, gam: gam),
    renderer: renderer,
    manifestSource: URLSessionManifestSource(),
    ticker: DispatchSchedulerTicker()
)

Task {
    try await sdk.startSession(SessionConfig(manifestUrl: "https://manifest.example.com/v1/your-org/channels/your-channel"))
}
GAM assets are skipped, with DA-GAM-CONFIG-MISSING, if the break manifest carries no usable gam entry in vendorConfiguration. That is a backend/provisioning fix — the identity is deliberately not configurable here.

Break transitions (transition)

How the picture changes at the edges of a break — the same option (and defaults) as on Android. The ad surface is held back until the ad has actually rendered its first frame (AVPlayerLayer.isReadyForDisplay), then cross-fades in over the content; at the end it dissolves away over resumed content. Nothing is shown while it is still blank, which is what removes the black flash at a fullscreen (single) break.

OptiViewAdsConfig(
    player: contentAdapter,
    transition: TransitionConfig()                          // fade, 300ms (default)
    // transition: TransitionConfig(type: .none)            // cut, no animation
    // transition: TransitionConfig(durationMs: 500)        // slower fade
)
Field Type Default Meaning
type .fade / .none .fade .none cuts, which is the behaviour before this option.
durationMs Int 300 Matches the web core's TRANSITION_MS. Ignored for .none.

An ad that renders no frame at all — an audio-only creative, or a decoder that never answers — is revealed anyway once the ad-start deadline passes, so it can never play invisibly. Every reveal emits DA-AD-SURFACE-REVEALED with reason (first-frame vs reveal-timeout) and waitedMs, the wait that would otherwise have been a black gap.

For a format that covers content (single, lshape_ad), content keeps playing under the fade and is paused as it lands, so the ad dissolves over moving video rather than over a still frame. Its audio is muted from the start of the break (and restored at the end, preserving a viewer's own mute), because two pictures may overlap during a cross-fade but two soundtracks may not.

Unlike Android there is no surface-type trade-off: an AVPlayerLayer alpha-blends natively, so fade costs nothing extra and works with DRM-protected creatives. And the renderer always warms ad media on its own layerless player, so the deferred content pause applies regardless of adPreload.

Picture-in-picture

The SDK owns picture-in-picture because the break format depends on it: while the window is open every break plays as single and the ad plays through the content player, since the window presents that player's surface and nothing else. Drive it with OptiViewAds.setPictureInPicture(_:); viewer-driven transitions (dismissing the window from the system UI) are detected and adopted automatically.

How the window is driven depends on the content adapter:

  • AVPlayerAdapter — the SDK discovers the AVPlayerLayer presenting your content and runs its own AVPictureInPictureController against it. Nothing to configure beyond the platform requirements below.
  • THEOplayer (or any adapter conforming to PictureInPictureCapable) — the window is driven through the player's own PiP facility (presentationMode), never an external controller: THEOplayer replaces its internal player/layer on a source change, which is exactly what happens when the ad borrows the content player, and an external controller's window dies at that moment. For the window to survive the ad hand-over you must construct the player with source-change retention — the SDK cannot set this after construction:
let pipConfig = PiPConfiguration(retainPresentationModeOnSourceChange: true)
let theoplayer = THEOplayer(configuration: THEOplayerConfiguration(pip: pipConfig, /* … */))

If you implement PictureInPictureCapable on your own adapter, note the change-handler contract: onPictureInPictureChange(_:) must also invoke the handler once with the current state at install time. The player outlives SDK sessions, so a window opened between sessions would otherwise go unreported and the next session would run breaks as if the window were closed. (The SDK also re-reads the state ~1s after every request it issues and diagnoses the outcome — DA-PIP-TRANSFER-FAILED when the player quietly refused.)

The renderer also reconciles the current PiP state at break setup and when ad media becomes ready, so a deferred adoption does not require a second enter notification. DA-PIP-ADOPTION-STATE explains deferred or already-applied decisions. On return from a shared-player VOD ad, the SDK marks the content reload as its own resume operation before resetting the source, preventing that backward jump from replaying the break.

Platform requirements, both silent when missing: the audio session must be in the .playback category (the SDK sets this when it drives the window), and the HOST app's Info.plist must declare UIBackgroundModes: audio — a capability an add-on framework cannot add for you. Without either, iOS ignores the start request with no error anywhere.

Existing Adapter — AVPlayerAdapter

The runtime ships AVPlayerAdapter, an AVFoundation implementation of PlayerAdapter. It wraps an AVPlayer you create and own (rendered in your own layer / AVPlayerViewController); the SDK drives the content player only through this adapter, keeping the brain player-agnostic.

import OptiViewAdsRuntime

let contentPlayer = AVPlayer()
let contentAdapter = AVPlayerAdapter(player: contentPlayer)
// → pass as OptiViewAdsConfig.player (and to OverlayAdRenderer).
Concern Behaviour
Events KVO on timeControlStatus / volume / isMuted / item status, a periodic time observer, and item end notifications are fanned out to the adapter's event handlers.
programDateTime Derived from AVPlayerItem.currentDate() as epoch milliseconds. Returns nil for VOD / streams without PDT, in which case wallclock-timebase breaks cannot be matched.
seek / seeked seek(_:) calls player.seek(to:) and fires the seeked event in its completion, enabling snapback enforcement during locked breaks.
load replaceCurrentItem — kicks off loading and returns immediately; readiness is signalled via playing / waiting / ended events.

THEOplayer Adapter — THEOplayerAdapter

The OptiViewAdsAdapterTHEOplayer product wraps the native THEOplayer player. With SwiftPM, add the OptiViewAdsAdapterTHEOplayer and OptiViewAdsCore xcframeworks as binary targets, plus theoplayer-sdk-apple from 11.0.0. The binary target does not declare THEOplayer, so add theoplayer-sdk-apple yourself and make the app target depend on its THEOplayerSDK product. The OptiViewAdsAdapterTHEOplayer CocoaPod pulls THEOplayerSDK-core (~> 11.0).

// Package.swift — THEOplayer binary target and app-target dependencies
dependencies: [
    .package(url: "https://github.com/THEOplayer/theoplayer-sdk-apple", from: "11.0.0"),
],
targets: [
    .binaryTarget(name: "OptiViewAdsCore",
        url: "<release base URL>/OptiViewAdsCore.xcframework.zip",
        checksum: "<see manifest.json>"),
    .binaryTarget(name: "OptiViewAdsAdapterTHEOplayer",
        url: "<release base URL>/OptiViewAdsAdapterTHEOplayer.xcframework.zip",
        checksum: "<see manifest.json>"),
    .target(name: "MyApp", dependencies: [
        "OptiViewAdsAdapterTHEOplayer", "OptiViewAdsCore",
        .product(name: "THEOplayerSDK", package: "theoplayer-sdk-apple"),
    ]),
]
import OptiViewAdsAdapterTHEOplayer

let contentAdapter = THEOplayerAdapter(player: theoplayer)
# Podfile — THEOplayer adapter
pod 'OptiViewAdsAdapterTHEOplayer', '<version>'

PlayerAdapter Protocol

Any content player is integrated by conforming to PlayerAdapter from OptiViewAdsCore. Subscriptions are tracked by an opaque PlayerEventToken (Swift closures are not identity-comparable), so on(_:_:) returns a token you pass to off(_:).

/// Event types a PlayerAdapter forwards.
public enum PlayerAdapterEvent: String {
    case timeupdate, ended, error, seeked, volumechange, waiting, playing
}

/// Handler payload carries adapter-specific detail (e.g. a media error).
public typealias PlayerAdapterEventHandler = (Any?) -> Void

/// Opaque token so a specific handler can later be removed via `off(_:)`.
public final class PlayerEventToken { public init() {} }

public protocol PlayerAdapter: AnyObject {
    /// Current playback time in seconds.
    var currentTime: Double { get }

    /// Total content duration in seconds; `.infinity` for live.
    var duration: Double { get }

    /// Whether the player is currently paused.
    var paused: Bool { get }

    /// Muted state. Settable so the SDK can sync content↔ad mute.
    var muted: Bool { get set }

    /// Volume in [0, 1]. Settable so the SDK can sync content↔ad volume.
    var volume: Double { get set }

    /// Program Date Time (EXT-X-PROGRAM-DATE-TIME) as epoch milliseconds,
    /// for wallclock-timebase break matching. Nil when unavailable.
    var programDateTime: Int64? { get }

    func pause()                  // called when an ad break starts
    func play()                   // called when an ad break ends
    func seek(_ time: Double)     // used for snapback during locked breaks
    func load(_ url: String)      // load without committing to immediate playback

    /// Optional: warm caches without engaging a decoder (single-decoder preload).
    func preload(_ url: String)

    /// Optional capability hint: can this adapter buffer a second source in
    /// parallel without decoder contention? Nil is treated as true.
    var supportsParallelBuffering: Bool? { get }

    /// Subscribe to a player event; returns a token for `off(_:)`.
    @discardableResult
    func on(_ event: PlayerAdapterEvent, _ handler: @escaping PlayerAdapterEventHandler) -> PlayerEventToken

    /// Unsubscribe a previously registered handler by its token.
    func off(_ token: PlayerEventToken)

    /// Clean up resources (called when the SDK is destroyed).
    func destroy()
}

// `preload` and `supportsParallelBuffering` have default implementations.
public extension PlayerAdapter {
    func preload(_ url: String) {}
    var supportsParallelBuffering: Bool? { nil }
}
Member Notes
programDateTime Epoch milliseconds (Int64?), not a Date — keeps the brain dependency-free. Critical for live streams with timebase: "wallclock"; return nil if the stream has no PDT.
load(_:) Kicks off loading and returns immediately. Signal readiness via the playing/waiting events rather than blocking.
seek(_:) Used by the SDK to snap back to a break start when controls.snapback is set and an unexpected seek is detected.
on / off The SDK listens to timeupdate for break timing, ended for asset completion, error for recovery, seeked for snapback, and volumechange / waiting / playing for state sync. Keep the returned token to unsubscribe.
preload / supportsParallelBuffering Optional with default implementations. Override preload only if your player supports a detached cache warm.

Implementing a Custom Adapter

To use the SDK with a different player, conform to PlayerAdapter and forward your player's callbacks to the SDK event handlers.

import OptiViewAdsCore

final class MyPlayerAdapter: PlayerAdapter {

    private let player: MyPlayer
    private var handlers: [PlayerAdapterEvent: [ObjectIdentifier: PlayerAdapterEventHandler]] = [:]

    init(player: MyPlayer) {
        self.player = player
        // Forward your player's callbacks to the SDK.
        player.onPositionChanged = { [weak self] in self?.dispatch(.timeupdate) }
        player.onEnded           = { [weak self] in self?.dispatch(.ended) }
        player.onError           = { [weak self] err in self?.dispatch(.error, err) }
        player.onSeeked          = { [weak self] in self?.dispatch(.seeked) }
        player.onVolumeChanged   = { [weak self] in self?.dispatch(.volumechange) }
    }

    // ── Required properties ──────────────────
    var currentTime: Double { player.positionSeconds }
    var duration: Double { player.isLive ? .infinity : player.durationSeconds }
    var paused: Bool { !player.isPlaying }

    var muted: Bool {
        get { player.volume == 0 }
        set { player.volume = newValue ? 0 : 1 }
    }

    var volume: Double {
        get { Double(player.volume) }
        set { player.volume = Float(max(0, min(1, newValue))) }
    }

    var programDateTime: Int64? { player.currentProgramDateEpochMs }   // nil for VOD / no PDT

    // ── Required methods ─────────────────────
    func pause() { player.pause() }
    func play()  { player.play() }
    func seek(_ time: Double) { player.seek(toSeconds: time) }
    func load(_ url: String)  { if let u = URL(string: url) { player.setSource(u) } }

    @discardableResult
    func on(_ event: PlayerAdapterEvent, _ handler: @escaping PlayerAdapterEventHandler) -> PlayerEventToken {
        let token = PlayerEventToken()
        handlers[event, default: [:]][ObjectIdentifier(token)] = handler
        return token
    }

    func off(_ token: PlayerEventToken) {
        let key = ObjectIdentifier(token)
        for event in handlers.keys { handlers[event]?.removeValue(forKey: key) }
    }

    func destroy() {
        handlers.removeAll()
        // Remove any observers registered on `player`.
    }

    private func dispatch(_ event: PlayerAdapterEvent, _ payload: Any? = nil) {
        handlers[event]?.values.forEach { $0(payload) }
    }
}
Key implementation notes:
• load(_:) and play() return immediately — signal readiness through playing/waiting events, never block.
• programDateTime must reflect the current stream position (epoch ms), not just the start of the manifest.
• Never share the content player instance with the ad renderer — the SDK owns its own ad player surface.

Validate your adapter

With SwiftPM, declare the OptiViewAdsAdapterTestKit and OptiViewAdsCore binary targets and depend on both from the test target only. Use the OptiViewAdsAdapterTestKit URL and checksum from the release manifest.json.

targets: [
    .binaryTarget(name: "OptiViewAdsCore",
        url: "<release base URL>/OptiViewAdsCore.xcframework.zip",
        checksum: "<see manifest.json>"),
    .binaryTarget(name: "OptiViewAdsAdapterTestKit",
        url: "<release base URL>/OptiViewAdsAdapterTestKit.xcframework.zip",
        checksum: "<see manifest.json>"),
    .testTarget(name: "MyPlayerAdapterTests", dependencies: [
        "OptiViewAdsAdapterTestKit", "OptiViewAdsCore",
    ]),
]
import OptiViewAdsAdapterTestKit
import OptiViewAdsCore

final class MyPlayerAdapterTests: PlayerAdapterConformanceTestCase {
    override func makeAdapter() -> PlayerAdapter {
        MyPlayerAdapter(player: makePlayer())
    }
}

Override triggerVolumeChange(_:) -> Bool when the player can synthesize a volume-change event headlessly.

SDK Configuration

Configuration is split into two levels: SDK-level (fixed for the app lifetime) and session-level (per channel / content piece).

Ad startup: Android and iOS/tvOS use a 10-second video ad-start deadline, including React Native, matching the web GAM pod budget. Other web ads retain their 5-second budget. These internal defaults require no configuration change; the separate 15-second break-start backstop and playback cutoff remain unchanged.

This topic is documented for every SDK. Use the Web / Android / iOS switcher at the top of the sidebar to choose your platform. The config shape is intentionally parallel across platforms (OptiViewAdsConfig + SessionConfig + GamConfig), minus the web-only DOM fields.

Web

OptiViewAdsConfig constructor

Property Type Required Description
player PlayerAdapter ✅ Adapter wrapping your content video player.
container HTMLElement ✅ Outer SDK stage element. The SDK appends its ad overlay and companion elements here. Must have position: relative (SDK sets it automatically if unset).
playerContainer HTMLElement ❌ Wrapper around your content video UI. The SDK animates this to a pip corner for L-shape formats. Optional — when omitted the SDK automatically wraps the existing children of container in a new <div>.
createAdAdapter (container: HTMLElement, video?: HTMLVideoElement) => PlayerAdapter ❌* Factory for the ad player. Receives a <div> container; create your media element(s) inside it and return a PlayerAdapter wrapping them. A second argument, video, is passed on the shared-element path — an EXISTING element (the content player's own <video>) the ad must play through so it reaches a picture-in-picture window or an OS-native fullscreen. Honour it by wrapping that element instead of creating one; a factory that ignores it is detected (the returned adapter reports a different videoElement) and the SDK falls back to asking the content player to load the ad itself. *Required with @dolby-optiview/ads-sdk-core; optional with @dolby-optiview/ads-sdk, which defaults to an HLS.js ad player (native <video> fallback).
adPreload 'parallel' | 'single-decoder' | 'none' | 'auto' ❌ Ad preload strategy. Default 'auto' picks parallel or single-decoder via User-Agent inspection — every Samsung (Tizen) / LG (webOS) / VIZIO (SmartCast) TV resolves to 'single-decoder' (a second attached MediaSource wedges those panels' only hardware decoder, PLAYG-319); everywhere else it resolves to 'parallel', and it never selects 'none'. 'parallel' buffers the next ad into the (attached) ad player while content plays. 'single-decoder' warms the HTTP cache + does a detached manifest/fragment prefetch (no second MediaSource) for single-decoder TVs. 'none' disables all pre-break activity — the cold-start baseline / opt-out. DA-PRELOAD-MODE-RESOLVED reports the resolved mode per session.
adBreakContentRecovery 'recreate' | 'none' | 'auto' ❌ How content is recovered when an ad break ends. Default 'auto' resolves to 'recreate' on VIZIO SmartCast (User-Agent match) and 'none' everywhere else; an explicit value always wins. 'recreate' releases the ad media, rebuilds the content player's media pipeline seeded at the resume position (via the adapter's recreateMediaPipeline), then plays — needed on VIZIO SmartCast, where content cannot resume in the MediaSource an ad interrupted. 'none' resumes in place (seek + play). DA-CONTENT-RECOVERY-MODE-RESOLVED reports the resolved mode per session; a failed or timed-out rebuild falls back to seek + play with DA-CONTENT-PIPELINE-RECREATE-FAILED. Web only: the Android, iOS and React Native bridges accept and ignore it.
adInsertion 'overlay' | 'shared-element' | 'adaptive' | 'auto' ❌ Ad insertion strategy. Default 'auto' keeps the overlay compositor everywhere except iPhone/iPod, where it switches to 'shared-element': a single fullscreen ad played through the content <video> so iOS native fullscreen is preserved. The SDK also falls back to shared-element for the duration of a break when the content player is in picture-in-picture and the browser will not hand the window to the ad element (WebKit) — a DOM overlay cannot draw into a window the browser renders itself. In shared-element mode advanced formats (double, lshape_ad, overlay) are downgraded to a single fullscreen ad and lshape_content is skipped. 'adaptive' is the iPhone/iOS 17.1+ (Managed Media Source) mode: overlay while inline, shared-element while in OS-native fullscreen. Force 'overlay' to disable, or 'shared-element' to opt in everywhere.
chaining { enabled?: boolean; maxGapSeconds?: number } ❌ Consecutive-break chaining. Default { enabled: true, maxGapSeconds: 2 }. When two breaks are separated by a gap ≤ maxGapSeconds, they play as one continuous sequence — the overlay is held across the gap (content is not resumed) and the next break is preloaded — so there is no flash or spinner between back-to-back breaks. Set enabled: false (or maxGapSeconds: 0) to always tear down between breaks.
tuneIn { enabled?: boolean; minBreakDurationSeconds?: number } ❌ Tune-in (join-in-progress) handling. Default { enabled: true, minBreakDurationSeconds: 5 }. When a viewer joins while a break is already in progress, the break is presented for its remaining duration instead of being skipped — unless less than minBreakDurationSeconds remains, in which case it is not monetised. The remaining duration also drives the GAM pod request. Set enabled: false to always skip in-progress breaks.
continueContentDuringBreak boolean ❌ Opt-in content-continuous single DAR playout. Requires explicit session adInsertionType: 'replacement'; it is independent of live/VOD, Anvato and timebase. Optional PlayerAdapter.setVideoQuality constrains bandwidth when supported; otherwise playout continues with DA-CONTINUE-PLAYOUT-QUALITY-UNAVAILABLE.
pdtGraceSeconds number ❌ How long to wait for the stream's EXT-X-PROGRAM-DATE-TIME on a wallclock-timebase session before concluding the stream carries none. Default 5. A session is normally started before the content player has loaded its source, so the first ticks see no PDT even on a stream that has it; those ticks make no scheduling decision rather than matching breaks against the system clock, which runs ahead of the live playhead by the stream's latency and would fire a break early. This is a backstop, not a fixed delay: the SDK stops waiting as soon as content has been playing for ~1s and still exposes no PDT (direct evidence about the stream — DA-PDT-MISSING is emitted once and the clock takes over immediately, so a join-in-progress break is never delayed below the tune-in minimum), and this window only governs a player that never starts. Set 0 for the immediate clock fallback. No effect on pts manifests.
adBreakCutSafetyMarginSec number ❌ Safety margin (seconds) added to the effective break duration. The hard-cut countdown starts at first actual ad playback, not loading or a resolved play() promise. Default 2; 0 allows exactly the effective duration from playback start. Buffering recovery and later ads do not restart it. A separate 15-second startup watchdog and existing load/start budgets prevent an ad that never starts from holding content.
doubleBoxAudio 'ad' | 'content' ❌ Which side gets audio during a double-format break (content and ad boxes side-by-side). Default 'ad' — the ad is audible, content is muted for the break. 'content' keeps content's audio and mutes the ad instead. The unified sdk.muted/sdk.volume control the focused side only; the pre-break state is restored to the non-focused side at break end. See Unified audio API.
gam GamConfig ❌ Google Ad Manager configuration. Required for GAM pod serving (and for mode: 'ssai').
mode 'sgai' | 'ssai' ❌ Ad-insertion architecture. Default: resolved from the break manifest's delivery rules on the first fetch (falling back to 'sgai' — see Delivery steering on the Break Manifest page). Setting it explicitly overrides manifest steering (DA-DELIVERY-MODE-OVERRIDDEN). Explicit 'ssai' plays a pre-stitched stream from the stitcher on the content player (see SSAI mode below).
maxBitrate number ❌ SSAI only — caps rendition bitrate via the master max_bitrate query param (bits/sec). The stitcher drops variants whose BANDWIDTH exceeds it (fail-open keeps the lowest if none qualify).
autoplay boolean ❌ SSAI only — when true the SDK plays the stitched stream after loading it during startSession. Default false.
interceptManifestResponse (manifest: BreakManifest, ctx: { url: string }) => BreakManifest | Promise<BreakManifest> ❌ SGAI only. Hook to inspect/modify the parsed, validated break manifest before it is scheduled — called on the initial fetch and on every poll, with the typed BreakManifest (never raw JSON). Return the manifest to use (modified or unchanged); the result is used directly, without re-validation. May be async. If it throws, the SDK emits DA-MANIFEST-INTERCEPT-FAILED and falls back to the unmodified manifest. See Manifest interception.
interceptManifestRequest (request: ManifestRequest, ctx: { url: string }) => ManifestRequest | ManifestMockResponse | void | Promise<…> ❌ SGAI only. Hook to inspect/modify the manifest HTTP request before it is fetched — called on the initial fetch and on every poll. Return a ManifestRequest ({ url; headers? }) to redirect/add headers, a ManifestMockResponse ({ body }, raw JSON parsed + validated normally) to short-circuit the network, or nothing to fetch unchanged. May be async. If it throws, the SDK emits DA-MANIFEST-REQUEST-INTERCEPT-FAILED and falls back to the network fetch. See Manifest interception.
breakWarnings { seconds?: number[] } ❌ Pre-break warning thresholds in seconds. Default { seconds: [] } (disabled). Each positive, finite value fires one adbreakstatus event with phase: 'upcoming' when the break is within that many seconds of starting, and again whenever the next (smaller) threshold is crossed. Duplicates and non-finite values are ignored and the list is sorted ascending. Useful for countdown UI.
ptsSource 'mediaTime' | 'anvatoCue' ❌ How a PTS-timebase break's start is resolved. Default 'mediaTime', which interprets start directly as player media time. Set 'anvatoCue' for Anvato in-stream break signaling (NFL Channel / NFL Network style); the SDK tracks Anvato timed-metadata cues from the content player — HLS ID3 GEOB frames with description Anvatos (MIME application/json), DASH emsg events with scheme urn:anvato:es1:052016, and DASH emsg events in the standardized ID3-in-emsg carriage (https://aomedia.org/emsg/ID3 or https://developer.apple.com/streaming/emsg-id3) whose payload is a complete ID3 tag with the same Anvato GEOB frame (the SDK unwraps it) — the Anvato payload is a query string type=cue&pts=<seconds>. A timebase: "pts" break is then scheduled at the media time of the observed cue whose pts matches the break's start (±5 ms); a break whose cue has not been observed yet stays pending (DA-ANVATO-CUE-PENDING, then DA-ANVATO-CUE-MATCHED once resolved). Cue-scheduled breaks are one-shot: once completed they are never re-armed by a backward playhead step (a DVR seek-back, or a live window re-anchoring on players that report window-relative positions). SGAI only; no effect on wallclock-timebase manifests.
diagnostics { bufferSize?: number } ❌ Structured diagnostics options. bufferSize (default 200) caps the in-memory ring buffer that backs exportDiagnostics(); older records are evicted first. See Diagnostics.
debug boolean ❌ Enable verbose SDK logging to the console. Default: false.

Ad preload behaviour (adPreload)

Controls how the next ad is prepared before a break starts.

Mode Behaviour Use when
parallel Buffers the next ad into the (attached) ad player while content keeps playing — lowest gap at break start. Requires two simultaneous MediaSources. Desktop, Android, and other parallel-capable platforms.
single-decoder Warms the HTTP cache and does a detached manifest/fragment prefetch without attaching a second MediaSource, deferring decode to break start. Any Samsung (Tizen), LG (webOS), or VIZIO SmartCast TV — including current models.
none No pre-break activity of any kind — no cache warm, no prefetch, no VAST pre-request. Full network + decode cost lands at break start. Cold-start measurement baseline, or when pre-break network activity is undesirable.
auto (default) Resolves at runtime via User-Agent inspection: Samsung/LG/VIZIO TVs → single-decoder, everything else → parallel. Never resolves to none. You ship one build across mixed devices.
The auto heuristic flags every Tizen (Samsung), webOS (LG), and VIZIO SmartCast TV, regardless of version. This is deliberately broad: a 2024 Tizen 8 flagship panel was measured deadlocking on a second attached MediaSource — content decode never starts, the screen stays black and no ad-break events fire (PLAYG-319) — so TV firmware version is not a reliable signal for decoder capacity. Anything outside those known single-decoder brands (desktop, mobile, and other TV platforms) is treated as parallel-capable.
Forcing adPreload: 'parallel' on a Samsung, LG, or VIZIO TV is still honoured (explicit config always wins over the heuristic), but is not recommended: on the panel above it reproducibly wedges playback for the whole session. Prefer 'auto', and use the DA-BREAK-TRANSITION diagnostic to measure what preload mode actually costs you at break start.
Measured on that same panel (Samsung QN86D, Tizen 8) with an HLS ad creative: single-decoder starts the break in ~1.1s vs ~7.1s cold (none) — the warm path removes essentially all network cost, and the remaining ~1s is decoder startup that no client-side preload can remove on a single-decoder pipeline. On CTV, serve streaming (HLS) ad creatives: a progressive MP4 plays through the native <video> pipeline where only HTTP cache warming applies (see DA-PRELOAD-PATH's creative field).

In single-decoder mode, content paused for a full-screen ad stays paused until the ad media is released. Live pre-positioning and muted catch-up behind the ad are disabled on this path; the existing release grace precedes the content resume seek and play. The default native ad adapter resets its owned media pipeline rather than only removing src. Parallel-mode alignment is unchanged.

Content recovery after a break (adBreakContentRecovery)

Controls how the content player is brought back when an ad break ends. This is a web-only option: the Android, iOS and React Native bridges accept it and ignore it, because their native players resume in place without the rebuild.

Mode Behaviour Use when
recreate Releases the ad media, rebuilds the content player's media pipeline seeded at the resume position (via the adapter's recreateMediaPipeline), then plays. VIZIO SmartCast TVs — see below.
none Resumes in place: seek to the resume position, then play. Everywhere else — the long-standing default.
auto (default) Resolves at runtime via User-Agent inspection: VIZIO/SmartCast → recreate, everything else → none. An explicit value always wins. You ship one build across mixed devices.
On VIZIO SmartCast the content player cannot resume in the MediaSource an ad interrupted — the same class of failure as the single-decoder release: the pipeline has to be rebuilt at the resume position rather than seeked inside. The rebuild re-buffers content (measured ~1s) instead of resuming from the existing buffer. An adapter that does not implement recreateMediaPipeline simply resumes in place; a rebuild that fails or exceeds its 10s budget falls back to seek + play (DA-CONTENT-PIPELINE-RECREATE-FAILED). DA-CONTENT-PIPELINE-RECREATED reports a successful rebuild with the resume position and duration.

Ad insertion behaviour (adInsertion)

Controls how the ad is composited relative to the content player.

Mode Behaviour
overlay Ad plays in a separate <video> layered over the content player. Supports all break formats. Safe on desktop, Android, iPad, and Smart TVs.
shared-element Ad plays through the content player's own <video> element so iOS native fullscreen is preserved. Only a single fullscreen ad is shown — advanced formats (double, lshape_ad, overlay) are downgraded to a single fullscreen ad and lshape_content is skipped. GAM/IMA DAI binds to the content element in this mode.
adaptive iPhone/iPod on iOS 17.1+ (Managed Media Source). Playback runs through HLS.js/MMS, so the SDK uses the full overlay compositor (all formats) while the content video is inline and falls back to shared-element (single fullscreen ad) only while the content video is in OS-native fullscreen. Re-evaluated per break from the content video's fullscreen state.
auto (default) On iPhone/iPod resolves to adaptive when Managed Media Source is available (iOS 17.1+), else shared-element (legacy, native HLS). Every other platform resolves to overlay.
DOM overlays cannot render over the OS-native fullscreen video player on iPhone/iPod. On iOS 17.1+ (adaptive) the SDK shows rich overlay formats while inline and degrades to a single shared-element ad only in fullscreen; on older iOS it uses shared-element throughout. iPad is intentionally excluded — iPadOS supports element-level fullscreen and MSE, so it stays on overlay.
AirPlay: Managed Media Source sets disableRemotePlayback=true, which disables AirPlay on the SDK-owned ad element (ads are not AirPlayed). To keep AirPlay on your content player, append an HLS <source> element to your own content <video>.

Web shared-element content return

After a shared-element ad, restoring content has at most two attempts, each with a five-second deadline covering load, live-edge recovery and play. A timeout or repeated rejection reports DA-SHARED-ELEMENT-RESTORE-FAILED with its phase and reason. Starting another break, ending the session or destroying the SDK cancels old recovery work so late SDK continuations cannot restart the wrong content. These limits do not cancel an adapter's own in-flight work.

The existing five-second progress watchdog still reports DA-CONTENT-RESUME-STALLED when an applicable completed restore does not advance. It does not monitor later viewer pauses indefinitely. A post-roll triggered by content ending restores the ended source but does not resume it.

Consecutive-break chaining (chaining)

Some channels schedule two (or more) breaks back to back — for example two breaks with different ad targeting parameters separated by a ~1s gap. Without chaining, each break ends independently: content briefly resumes, the layout animates back to fullscreen, then the next break re-pauses content and re-applies its layout — a visible flash, often followed by a spinner while the next ad loads.

With chaining enabled (the default), breaks whose gap is ≤ maxGapSeconds are played as one continuous ad sequence:

  • The ad overlay is held across the gap — content is never resumed and the layout is not torn down (no flash).
  • The next break's first (static) asset is preloaded during the current break (HTTP cache warm + a detached prefetch), so it starts without a spinner. This never attaches a second MediaSource, so it is safe on single-decoder Smart TVs.
  • Each break still emits its own adbreakbegin / adbreakend events and resolves its own targeting, so analytics and per-break ad requests are unchanged.
Option Default Description
enabled true Whether chaining is active. false restores per-break teardown (content resume + layout reset between breaks).
maxGapSeconds 2 Maximum gap between one break's end and the next break's start for them to chain. 0 effectively disables chaining.
Chaining holds the overlay across the gap, so on a live stream the content stays paused for the gap plus both breaks and falls further behind the live edge. Chaining is not applied in shared-element (iPhone) mode, where breaks always hard-end.
const sdk = new OptiViewAds({
  // ...
  chaining: { enabled: true, maxGapSeconds: 2 },
});

Tune-in / join-in-progress (tuneIn)

Live viewers rarely join exactly at a break boundary — they often tune in while an ad break is already playing. By default the SDK detects this and presents the break for the time that remains, rather than skipping it entirely (the legacy behaviour, which left the join-in-progress window unmonetised).

With tune-in enabled (the default), when the SDK first observes a break that has already started:

  • If at least minBreakDurationSeconds of the break remain, the break is triggered for its remaining duration. The adbreakbegin event carries a tuneIn: { elapsedSec, remainingSec } payload, and the break-cut countdown allows that remaining duration plus the safety margin from actual ad playback. Startup does not consume the remainder; content may return after the original boundary.
  • For GAM pod serving, the pod request is built for the remaining duration, so the ad server returns a pod that fits the time left.
  • If less than minBreakDurationSeconds remain, the break is not triggered (no partial sliver of an ad is shown, and it is not monetised).
Option Default Description
enabled true Whether in-progress breaks are joined. false restores the legacy behaviour of skipping any break first observed past its start.
minBreakDurationSeconds 5 Minimum remaining duration for an in-progress break to be presented. Below this, the break is skipped.
Tune-in only applies to a break the SDK first observes after its start time (e.g. a fresh join or a seek into a break). A break the SDK has already triggered on time is unaffected and always plays its full duration.
const sdk = new OptiViewAds({
  // ...
  tuneIn: { enabled: true, minBreakDurationSeconds: 5 },
});

SSAI mode (mode: 'ssai')

By default the SDK runs SGAI (client-scheduled) insertion, unless the break manifest's delivery rules steer the session to ssai (see Delivery steering on the Break Manifest page — manifest-steered ssai uses the gam vendor configuration's assetKey and needs no stitcherUrl). Set mode: 'ssai' explicitly to switch to the stitcher-based server-side ad insertion: the SDK plays a single pre-stitched stream from the @dolby-optiview/ads-sdk-stitcher service on the content player. It does not poll a break manifest, schedule breaks, or run an ad-player overlay. Instead it:

  1. creates the IMA DAI stream (for a stream_id),
  2. builds the stitcher master URL and loads it into the content player,
  3. forwards in-stream timedmetadata cues to IMA for ad tracking, and
  4. re-emits the usual ad-event stream (adbreakbegin / adbegin / quartiles / adend / adbreakend).
const sdk = new OptiViewAds({
  mode: 'ssai',
  player: new HlsJsAdapter(hls, video), // must expose videoElement (IMA binds to it)
  gam: {},
  ssai: { networkCode: '23285652104' }, // SSAI only: no break manifest to read it from
  maxBitrate: 2000000, // optional — caps rendition bitrate (max_bitrate on the master URL)
  autoplay: true, // optional — SDK plays the stitched stream after load
});

// The SDK loads the stitched master itself — do not load a content URL.
await sdk.startSession({
  stitcherUrl: 'https://stitch.example.com/ssai/v1/your-org/your-channel/master.m3u8',
  customAssetKey: 'asset-key',
});
mode: 'ssai' requires a gam object and ssai.networkCode (SSAI has no break manifest to read the identity from); startSession requires stitcherUrl and customAssetKey. The content adapter must expose videoElement (HLS.js, Shaka, and native-video qualify). Startup failures emit DA-SSAI-SESSION-FAILED; in-stream IMA errors emit DA-SSAI-IMA-ERROR. See the SSAI Stitcher page for the full topology.

Manifest interception (interceptManifestRequest / interceptManifestResponse)

Two optional SGAI hooks bracket the manifest fetch:

  • interceptManifestRequest runs before the network call (initial fetch + every poll). Return a ManifestRequest to redirect the URL or add headers, a ManifestMockResponse to short-circuit the network with a raw body (parsed + validated normally), or nothing to fetch unchanged. A throw is non-fatal (DA-MANIFEST-REQUEST-INTERCEPT-FAILED, falls back to the network fetch). Primarily a testing aid (mock/redirect without a proxy).
  • interceptManifestResponse runs after fetch + validation, handing you the parsed, validated BreakManifest so you can transform it — inject a pre-roll, drop a break, rewrite an asset URL — before it is scheduled. The return value is used as-is; a throw is non-fatal (DA-MANIFEST-INTERCEPT-FAILED, falls back to the unmodified manifest).

Full guarantees, code examples, and the Android/iOS equivalents are documented under Manifest interception on the Break Manifest page.

SessionConfig startSession()

Property Type Required Description
manifestUrl string ✅* SGAI only. Full break-manifest URL. The SDK fetches and polls exactly this URL — it never composes, rewrites or appends to it. *Replaced by stitcherUrl when mode: 'ssai'.
stitcherUrl string ✅* SSAI only, and required there instead of manifestUrl: the full stitcher master-playlist URL (…/ssai/v1/{orgId}/{channelId}/master.m3u8). Used verbatim, with stream_id/max_bitrate appended.
assetParameters Record<string, string> ❌ Ad parameters for this session's assets (targeting, custom params). Merged on top of each asset's own assetParameters from the break manifest, per key. updateAssetParameters() overrides both.

GamConfig optional

Property Type Required Description
streamActivityMonitorId string ❌ Stream Activity Monitor id, for debugging in Google's SAM tool.
The GAM stream identity (networkCode, customAssetKey) is not configured on the client. It comes from the break manifest's root vendorConfiguration, so the SDK and the backend can never disagree about which GAM asset the pods belong to. An empty gam object is what enables GAM; if the manifest carries no usable gam entry the SDK emits DA-GAM-CONFIG-MISSING and skips GAM assets.
| streamActivityMonitorId | string | ❌ | Debug session ID for Google's Stream Activity Monitor tool. |

Example

const sdk = new OptiViewAds({
  player: new HlsJsAdapter(hls, video),
  container: document.getElementById('container'),
  playerContainer: document.getElementById('playerContainer'),
  createAdAdapter: (adContainer) => {
    const adVideo = document.createElement('video');
    adContainer.appendChild(adVideo);
    const adHls = new Hls();
    adHls.attachMedia(adVideo);
    return new HlsJsAdapter(adHls, adVideo);
  },
  gam: {
    streamActivityMonitorId: 'my-debug-session',
  },
  debug: false,
});

Simplified setup with @dolby-optiview/ads-sdk

Import OptiViewAds from @dolby-optiview/ads-sdk to drop the createAdAdapter boilerplate — the ad player defaults to HLS.js (with a native <video> fallback). Use adPreload: 'auto' to enable single-decoder preloading on Smart TVs.

import { OptiViewAds, HlsJsAdapter } from '@dolby-optiview/ads-sdk';

const sdk = new OptiViewAds({
  player: new HlsJsAdapter(hls, video),
  container: document.getElementById('container'),
  // createAdAdapter omitted -> default HLS.js ad player (native fallback)
  adPreload: 'auto',
});

Android (Kotlin)

OptiViewAdsConfig (com.dolby.optiview.ads.sdk) carries the org-level options. The web-only DOM fields (container, playerContainer, createAdAdapter) do not exist on Android — the ad surface is owned by the OverlayAdRenderer you pass to the OptiViewAds constructor (its overlayContainer FrameLayout is the Android equivalent of container).

Property Type Default Description
player PlayerAdapter — (required) Adapter wrapping your content player (e.g. ExoPlayerAdapter).
interceptManifestResponse (suspend (BreakManifest, ManifestInterceptContext) -> BreakManifest)? null SGAI only. Inspect/modify the parsed BreakManifest before scheduling (initial fetch + every poll); may suspend. On throw emits DA-MANIFEST-INTERCEPT-FAILED and uses the unmodified manifest. Forwarded to the injected ManifestSource (default HttpManifestSource applies it).
interceptManifestRequest (suspend (ManifestRequest, ManifestRequestContext) -> ManifestRequestResult?)? null SGAI only. Inspect/modify the manifest request before fetch (initial fetch + every poll); may suspend. Return a ManifestRequest (redirect/headers), a ManifestMockResponse (raw body, parsed + validated), or null. On throw emits DA-MANIFEST-REQUEST-INTERCEPT-FAILED and falls back to the network fetch. Forwarded to the injected ManifestSource (default HttpManifestSource applies it).
adPreload AdPreloadMode PARALLEL PARALLEL / SINGLE_DECODER / AUTO. On Android (no browser UA) AUTO resolves to PARALLEL.
adInsertion AdInsertionMode AUTO OVERLAY / SHARED_ELEMENT / ADAPTIVE / AUTO. On Android AUTO resolves to OVERLAY.
chaining ChainingConfig ChainingConfig(enabled = true, maxGapSeconds = 2.0) Consecutive-break chaining (same semantics as web).
tuneIn TuneInConfig TuneInConfig(enabled = true, minBreakDurationSeconds = 5.0) Tune-in / join-in-progress handling (same semantics as web).
continueContentDuringBreak Boolean false Opt-in content-continuous single DAR playout. Requires explicit SessionConfig.adInsertionType = AdInsertionType.REPLACEMENT; independent of live/VOD, Anvato and timebase. Optional PlayerAdapter.setVideoQuality constrains bandwidth when supported; otherwise playout continues with a warning. ExoPlayerAdapter and THEOplayerAdapter implement it.
pdtGraceSeconds Double 5.0 Backstop window for the stream's EXT-X-PROGRAM-DATE-TIME to appear on a wallclock session before falling back to the system clock with DA-PDT-MISSING (same semantics as web). Ticks before then make no scheduling decision; once content is playing with still no PDT the fallback happens immediately. 0.0 = immediate fallback.
adBreakCutSafetyMarginSec Double 2.0 Seconds added to a break's effective duration to form the hard cut-off back to content, regardless of ad media length (counted from first actual ad playback, like Web). Set 0.0 to allow exactly the effective playback duration.
diagnostics DiagnosticsConfig DiagnosticsConfig(bufferSize = 200) Diagnostic ring-buffer size backing exportDiagnostics().
debug Boolean false Verbose logging via println.
gam GamConfig? null Google Ad Manager pod-serving config.

SessionConfig(manifestUrl, assetParameters = emptyMap()) and GamConfig(streamActivityMonitorId? = null) mirror the web shapes.

import com.dolby.optiview.ads.sdk.*

val sdk = OptiViewAds(
    config = OptiViewAdsConfig(
        player = ExoPlayerAdapter(contentPlayer),
        adPreload = AdPreloadMode.PARALLEL,
        chaining = ChainingConfig(enabled = true, maxGapSeconds = 2.0),
        tuneIn = TuneInConfig(enabled = true, minBreakDurationSeconds = 5.0),
        continueContentDuringBreak = true,
        diagnostics = DiagnosticsConfig(bufferSize = 200),
        gam = GamConfig(
            streamActivityMonitorId = "my-debug-session",
        ),
        debug = false,
    ),
    renderer = OverlayAdRenderer(context, overlayContainer, contentAdapter, gamConfig = gam),
    manifestSource = HttpManifestSource(scope),
    ticker = CoroutineSchedulerTicker(scope),
)

iOS / tvOS (Swift)

OptiViewAdsConfig (OptiViewAdsSDK, re-exported by OptiViewAdsRuntime) carries the org-level options. As on Android, the web-only DOM fields are omitted — the ad surface is owned by the OverlayAdRenderer (its overlayContainer UIView). continueContentDuringBreak is not supported on iOS/tvOS yet; Apple integrations retain pause/resume behavior.

Property Type Default Description
player PlayerAdapter — (required) Adapter wrapping your content player (e.g. AVPlayerAdapter).
interceptManifestResponse ManifestResponseInterceptor? nil SGAI only. Inspect/modify the parsed BreakManifest before scheduling (initial fetch + every poll); may be async/throwing. On throw emits DA-MANIFEST-INTERCEPT-FAILED and uses the unmodified manifest. Forwarded to the injected ManifestSource (default URLSessionManifestSource applies it).
interceptManifestRequest ManifestRequestInterceptor? nil SGAI only. Inspect/modify the manifest request before fetch (initial fetch + every poll); may be async/throwing. Return .request (redirect/headers), .mock (raw body, parsed + validated), or nil. On throw emits DA-MANIFEST-REQUEST-INTERCEPT-FAILED and falls back to the network fetch. Forwarded to the injected ManifestSource (default URLSessionManifestSource applies it).
adPreload AdPreloadMode .parallel .parallel / .singleDecoder / .auto. .auto resolves to .parallel on Apple platforms.
adInsertion AdInsertionMode .auto .overlay / .sharedElement / .adaptive / .auto. .auto resolves to .overlay on tvOS / non-iPhone.
chaining ChainingConfig ChainingConfig(enabled: true, maxGapSeconds: 2.0) Consecutive-break chaining.
tuneIn TuneInConfig TuneInConfig(enabled: true, minBreakDurationSeconds: 5.0) Tune-in / join-in-progress handling (same semantics as web).
pdtGraceSeconds Double 5.0 Backstop window for the stream's EXT-X-PROGRAM-DATE-TIME to appear on a wallclock session before falling back to the system clock with DA-PDT-MISSING (same semantics as web). Ticks before then make no scheduling decision; once content is playing with still no PDT the fallback happens immediately. 0 = immediate fallback.
adBreakCutSafetyMarginSec Double 2.0 Seconds added to a break's effective duration to form the hard cut-off back to content, regardless of ad media length (counted from first actual ad playback, like Web). Set 0.0 to allow exactly the effective playback duration.
diagnostics DiagnosticsConfig DiagnosticsConfig(bufferSize: 200) Diagnostic ring-buffer size.
debug Bool false Verbose logging via print.
gam GamConfig? nil Google Ad Manager pod-serving config.

SessionConfig(manifestUrl:assetParameters:) and GamConfig(streamActivityMonitorId:) mirror the web shapes.

import OptiViewAdsRuntime

let gam = GamConfig(
    streamActivityMonitorId: "my-debug-session"
)

let sdk = OptiViewAds(
    config: OptiViewAdsConfig(
        player: AVPlayerAdapter(player: contentPlayer),
        adPreload: .parallel,
        chaining: ChainingConfig(enabled: true, maxGapSeconds: 2.0),
        tuneIn: TuneInConfig(enabled: true, minBreakDurationSeconds: 5.0),
        continueContentDuringBreak: true,
        diagnostics: DiagnosticsConfig(bufferSize: 200),
        debug: false,
        gam: gam
    ),
    renderer: OverlayAdRenderer(overlayContainer: overlayContainer, contentPlayer: contentAdapter, gamConfig: gam),
    manifestSource: URLSessionManifestSource(),
    ticker: DispatchSchedulerTicker()
)

Session Management

A session represents the monetization lifecycle for one piece of content. Call startSession() before loading a stream and endSession() when done.

This topic is documented for every SDK. Use the Web / Android / iOS switcher at the top of the sidebar to choose your platform. The lifecycle is identical across platforms — only the async idiom differs (Promise on web, suspend on Android, async throws on iOS).

React Native: keep playback ownership separate from UI

@dolby-optiview/ads-sdk-react-native exports OptiViewAdsSession and useOptiViewAds on native and web. The session owns one connector startup and safe asynchronous cleanup; the hook observes its lifecycle without debug polling.

Use useOptiViewAds({ ownership: 'hook', createSession }) for ordinary components, with a memoized factory returning new OptiViewAdsSession(connector, sessionConfig) or null until the player is ready. The hook destroys its session on unmount and waits for cleanup before replacing it when the factory changes.

For a floating player, retain the session in the application's playback owner and use useOptiViewAds({ ownership: 'external', session }). UI unmount only removes observation; manifest polling and native playback continue. The owner must retain the actual native player too, and call await session.destroy() on real close. Restoring UI observes the same session instead of starting another. This ownership choice is independent of program-boundary targeting updates, which use the connector's updateAssetParameters and updateAssetParameterMacros methods.

Reusable React Native player wiring

useTHEOplayerAdsBinding({ config, adsConfig, sessionConfig, enabled }) returns playerConfig, playerHandlers, and a stable createSession factory. Pass the ready/destroy handlers and configuration to THEOplayerView, and the factory to useOptiViewAds({ ownership: 'hook', createSession }). Equivalent configuration values do not restart ads; changed values or macro-callback identities do. Memoize callback-valued macros and use connector targeting updates when a restart is not wanted. For app-owned floating playback, retain the session outside React and call playerHandlers.onPlayerRestored(player) after a handoff; that callback is an application integration point, not a player-view prop.

Optional useOptiViewAdsDebug({ connector: ads.connector, status: ads.status, enabled: debugEnabled }) exposes bounded event summaries, ad metadata, sampled break/presentation state, history clearing and diagnostic export. It adds no UI and never owns playback. Disable it to remove automatic subscriptions and polling. History omits raw payloads; metadata is intended for local inspection, not telemetry.

Web

startSession(config: SessionConfig): Promise<void>

Starts manifest polling and (if GAM is configured) initializes the IMA stream session. Resolves when the first manifest has been fetched successfully.

await sdk.startSession({
  manifestUrl: 'https://manifest.example.com/v1/your-org/channels/d7803a87-465a-4e43-b12e-6f479be119a1',
  customAssetKey: 'my-asset-key',
  adInsertionType: 'replacement', // 'replacement' (DAR, default) | 'insertion' (DAI)
  assetParameters: { cust_params: 'genre=sports' },
});
// Now load and play your content
hls.loadSource(contentUrl);
video.play();

Immediate pre-rolls hold content automatically. When the first manifest contains a content-covering pre-roll (start: { type: 'event', event: 'start' }, or the legacy position: 'pre') with no delay (or delay: 0), the SDK holds the content player at session start — pausing it and re-pausing if your own play() (above) starts it — so no content frames are shown before the ad. The pre-roll begins once the content player reports its media is loaded, or after 10 seconds. If content fails to load first, the pre-roll is cancelled and the SDK reports DA-PREROLL-CANCELLED-CONTENT-ERROR. The hold is released the moment the pre-roll begins (the break then owns content pause/resume) and on endSession(). A delay > 0 pre-roll plays content first: the delay counts played media time from the first content frame (paused time and seeks do not count). Reported via the DA-PREROLL-CONTENT-HELD diagnostic.

adInsertionType (DAR vs DAI, SGAI only). Controls how a break relates to the content timeline. replacement (DAR, the default) keeps the historical resume behaviour — the replaced content window is skipped only when a break carries an explicit resumeOffset. This is intended, not a bug: without a manifest resumeOffset, DAR resumes content from the same position the break started at (the classic "replace this window of content with an ad, then continue from where it left off" model); set resumeOffset on the break when you want DAR to skip forward past the replaced window. insertion (DAI) resumes content at the exact pre-break position. A break's manifest resumeOffset overrides the mode default in both cases. Resume-seek is applied on the positional pts and mediatime timebases for content-pausing break formats; it is ignored in SSAI mode (the stitcher controls insertion server-side, reported via the DA-INSERTION-TYPE-IGNORED-SSAI diagnostic). The demo's Ad Insertion Type selector drives this (and the ?adInsertionType= URL param). The dedicated VOD (DAR/DAI) demo page lets you contrast the two against your own VOD asset and break manifest: pick DAR or DAI, paste a PTS mid-roll manifest (provisioned via the break-manifest server — the hosted one on the deployed demo, or a local one under npm run dev), and watch the resume behaviour differ on break end.


endSession(): void

Stops manifest polling and resets the GAM session. Call this when the user navigates away, changes channel, or stops playback.

endSession() is for channel changes made by the application (a different manifestUrl). When the backend re-points the same manifest URL to another ads channel, the SDK handles it inside the running session and emits adchannelchange — see Channel switch on the Manifest page.

On an ads-channel switch, delivery mode is resolved again. The adchannelchange event reports previousDeliveryMode and deliveryMode; after an ssai to sgai switch, load the content stream in the application after receiving the event.

sdk.endSession();

updateAssetParameters(params: Record<string, string>): void

Replaces the live-update layer of the asset parameters. Useful for changing targeting (a sport segment change, a consent update) without restarting the session.

The per-key precedence is SessionConfig.assetParameters < updateAssetParameters() < the manifest session layer (vendorConfiguration.gam.sgai[0].assetParameters or ssai[0].assetParameters) < the per-asset assetParameters map. It replaces the previous update rather than accumulating onto it.

Applies to future ad breaks — a break whose ad request has already gone out cannot be retargeted. Consumed by GAM pods (IMA ad-tag parameters) and by VAST assets (appended to the tag URL as query parameters, except inline data: tag URLs, which have no query component).

sdk.updateAssetParameters({ cust_params: 'sport=basketball' });

updateAssetParameterMacros(macros: AssetParameterMacroUpdates): void

Merges the supplied macro names into the session table. null marks a customer macro empty so parameters using it are not sent; undefined unregisters the macro so a built-in value can apply again. Names not supplied remain unchanged. An active GAM stream receives the re-resolved targeting through replaceAdTagParameters(), while VAST uses the new values on its next request. An ad already loaded is not re-requested.

sdk.updateAssetParameterMacros({ '$CUSTOM_TEAM#x27;: 'basketball', '$OLD_TEAM#x27;: undefined });

Asset-parameter macros (SessionConfig.assetParameterMacros)

type AssetParameterMacroValue = string | null | undefined;
type AssetParameterMacro = AssetParameterMacroValue | (() => AssetParameterMacroValue);
type AssetParameterMacros = Record<string, AssetParameterMacro>;

startSession() accepts asset-parameter macros as assetParameterMacros. Every full $NAME$ token found inside an asset-parameter value (from the manifest, the session, or updateAssetParameters()) is replaced by macros['$NAME#39;] when the ad request is built: a string replaces the token, null marks it empty, and a callback is invoked on every request (for values that change over time). A value may hold several macros next to literal text — "$OPTIVIEW_PLAYER_WIDTH$x$OPTIVIEW_PLAYER_HEIGHTquot; becomes "1920x1080". Names are case-sensitive, and $APP$ and $APP_NAME$ never collide. A $ without a closing $ is left unchanged. Break-duration macros are omitted from session-level GAM/SSAI requests. The macros are cleared by endSession().

Macro values have three states:

  1. A string replaces the token.
  2. null marks the macro empty. The parameter is not sent. For GAM cust_params, only the &-separated pair containing the macro is dropped. If no pair remains, the cust_params key is omitted. For VAST, the whole cust_params parameter is omitted.
  3. undefined or another unresolved macro leaves the token literal. The parameter is still sent.

The built-in macros are resolved by the SDK and can be overridden by registering the same name. Their names are exported as OPTIVIEW_ASSET_PARAMETER_MACROS (USER_AGENT, PLAYER_WIDTH, PLAYER_HEIGHT, BREAK_DURATION_MS, BREAK_DURATION_SECONDS):

Macro Value
$OPTIVIEW_USER_AGENT$ navigator.userAgent (Android: http.agent; iOS: none — register your own)
$OPTIVIEW_PLAYER_WIDTH$ Ad container width in logical pixels (CSS px / dp / pt)
$OPTIVIEW_PLAYER_HEIGHT$ Ad container height in logical pixels (CSS px / dp / pt)
$OPTIVIEW_BREAK_DURATION_MS$ Ad duration in whole milliseconds (the asset's duration, else the break's)
$OPTIVIEW_BREAK_DURATION_SECONDS$ The same duration in seconds (30, 15.5)

Resolution order and fallbacks:

  1. A customer macro wins over a built-in $OPTIVIEW_* name.
  2. Otherwise the built-in value is used.
  3. A macro that resolves to null omits its parameter. For GAM cust_params, only the pair containing the macro is dropped. For VAST, the whole cust_params parameter is omitted.
  4. An unresolved $OPTIVIEW_* name (unknown, or a registration that is / returns undefined) is kept literally in the value; the parameter is still sent and emits DA-ASSET-MACRO-UNKNOWN once per parameter key and macro (context: key, macro).
  5. An unresolved complete token with any other prefix ($PUBLISHER_ID$, ${OPTIVIEW_X}, …) is kept literally in the value without a diagnostic, so vendor-side macros pass through untouched.

updateAssetParameterMacros() uses null to mark a macro empty and undefined to unregister it. DA-ASSET-PARAMETER-OMITTED reports parameters omitted because a macro resolved to null; DA-ASSET-MACRO-UNKNOWN reports unresolved built-in or registered macros.

import { OPTIVIEW_ASSET_PARAMETER_MACROS } from '@dolby-optiview/ads-sdk';

const assetParameterMacros = {
  '$SEGMENT#x27;: 'sports', // fixed value
  '$CUSTOM_TEAM#x27;: () => currentTeam(), // evaluated on every ad request
  '$CUSTOM_USER_ID#x27;: () => user?.id, // undefined → sent literally as "$CUSTOM_USER_IDquot;
  [OPTIVIEW_ASSET_PARAMETER_MACROS.USER_AGENT]: 'MyApp/2.1 (SmartTV)', // overrides the built-in
};

await sdk.startSession({
  manifestUrl,
  assetParameterMacros,
  assetParameters: {
    pod_duration: '$OPTIVIEW_BREAK_DURATION_SECONDS#x27;,
    dimensions: '$OPTIVIEW_PLAYER_WIDTH$x$OPTIVIEW_PLAYER_HEIGHT#x27;, // macros inside a value → "1920x1080"
  },
});

In this demo, the Asset Parameter Macros field of the Session card takes the same map as JSON. Only strings and null can be written in JSON: {"$SEGMENTquot;: "sports", "$CUSTOM_TEAMquot;: null} sends $SEGMENT$ as sports and omits every parameter that contains $CUSTOM_TEAM$. Callbacks and undefined are not JSON; leave a macro out of the map to keep its token literal. The parsed map is passed to startSession() unchanged. The omission is visible after Load: the event log shows DA-ASSET-PARAMETER-OMITTED with the omitted key and macro, and the SDK debug log in the browser console (Preloading VAST ads: <url>) shows the tag URL without that key. After Load, the Update asset parameter macros input ($NAME$=value, $NAME$=null, $NAME$=undefined) calls updateAssetParameterMacros() for the next ad request.


destroy(): void

Tears down the entire SDK instance, releasing all resources. Call this when the player is unmounted.

sdk.destroy();

play(): Promise<void>

Proxy for contentPlayer.play(). Suppressed automatically during content-locking ad breaks (e.g. single format) so the ad is not interrupted. Prefer this over calling your player directly so break policies are honoured.

await sdk.play();

pause(): void

Proxy for contentPlayer.pause(). No-op during a content-locking ad break.

sdk.pause();

seek(time: number): void

Seek the content stream to a position in seconds. Blocked when the active break has controls.snapback: true. The SDK also listens to the native seeked event from the PlayerAdapter and will snap back automatically even if the customer seeks directly on the underlying player.

sdk.seek(180); // seek to 3:00 in the content stream

muted: boolean

Unified mute state. Reading or writing sdk.muted always targets whichever player is currently audible — the ad player while a break is playing, the content player otherwise — so a single mute control works correctly across break transitions in both directions. Setting it also bridges into IMA for a VAST/CSAI ad in progress, since IMA drives its own audio path independently of the underlying <video> element.

sdk.muted = true; // mute whatever is currently playing
const isMuted = sdk.muted;

volume: number

Unified volume (0-1). Same effective-player targeting as muted above.

sdk.volume = 0.5;

Event: volumechange

Fires whenever the unified mute/volume state changes — via the muted/volume setters above, a direct mutation of the content or ad element that the SDK's internal sync picked up, or an IMA CSAI ad's own UI. Carries the resulting muted/volume values so a UI can keep a mute button's label correct without polling:

muteButton.onclick = () => {
  sdk.muted = !sdk.muted;
};
sdk.addEventListener('volumechange', (e) => {
  muteButton.textContent = e.muted ? 'Unmute' : 'Mute';
});
Web (@dolby-optiview/ads-sdk-core) only for now — see Custom Player UI for a full worked example, and Events for the event payload shape. Android/iOS parity is tracked as a follow-up.

Lifecycle example

// App startup
const sdk = new OptiViewAds({ ... });

// User starts watching a channel
await sdk.startSession({ manifestUrl, customAssetKey });
hls.loadSource(channelUrl);

// User switches to another channel
sdk.endSession();
await sdk.startSession({ manifestUrl: newManifestUrl, customAssetKey: newKey });
hls.loadSource(newChannelUrl);

// App teardown
sdk.destroy();

Android (Kotlin)

The lifecycle methods mirror the web API. startSession is a suspend function (call it from a coroutine); the rest are synchronous. The method set is startSession / endSession / updateAssetParameters / updateAssetParameterMacros / play / pause / seek / destroy, plus isSessionActive() / isAdPlaying().

Method Signature Notes
startSession suspend fun startSession(config: SessionConfig) Begins polling + GAM init. Ends any active session first. Throws if the first manifest fetch fails.
endSession fun endSession() Stops polling and resets the GAM session. No-op if inactive.
updateAssetParameters fun updateAssetParameters(params: Map<String, String>) Replaces the live-update layer of the asset parameters (GAM + VAST). No-op (logs) without an active session.
updateAssetParameterMacros fun updateAssetParameterMacros(macros: Map<String, (() -> String?)?>) Merges the given macros into the session macros (other names stay); null marks a name empty and undefined unregisters it. Applies to the next ad request. No-op (logs) without an active session.
SessionConfig.assetParameterMacros Map<String, () -> String?> Customer macros as zero-argument callbacks (a fixed value is a constant callback, { "sports" }); a string replaces the token, null omits the parameter, and undefined leaves it literal. GAM cust_params drops only the containing pair; VAST omits the whole parameter. Built-in names: OptiViewAssetParameterMacros.
play / pause fun play() / fun pause() Proxy the content player; no-op while a content-locking break is active.
seek fun seek(time: Double) Seconds. Blocked during breaks with controls.snapback. No-op with no session.
destroy fun destroy() Tears down the SDK, detaches player listeners, cancels the scope.
// User starts watching a channel
scope.launch {
    sdk.startSession(
        SessionConfig(
            manifestUrl = "https://manifest.example.com/v1/your-org/channels/d7803a87-465a-4e43-b12e-6f479be119a1",
            customAssetKey = "my-asset-key",
            assetParameters = mapOf(
                "cust_params" to "genre=sports",
                "dimensions" to "\$OPTIVIEW_PLAYER_WIDTH\$x\$OPTIVIEW_PLAYER_HEIGHT\quot;, // macros inside a value → "1920x1080"
            ),
            assetParameterMacros = mapOf("\$CUSTOM_TEAM\quot; to { currentTeam() }),
        ),
    )
    contentPlayer.setMediaItem(MediaItem.fromUri(channelUrl))
    contentPlayer.prepare()
    contentPlayer.playWhenReady = true
}

// Update targeting mid-session (no restart)
sdk.updateAssetParameters(mapOf("cust_params" to "sport=basketball"))
sdk.updateAssetParameterMacros(mapOf("\$CUSTOM_TEAM\quot; to { "lakers" }))

// Switch channel
sdk.endSession()
scope.launch { sdk.startSession(SessionConfig(manifestUrl = newManifestUrl, customAssetKey = newKey)) }

// Teardown (then release your ExoPlayer)
sdk.destroy()

iOS / tvOS (Swift)

startSession is async throws (call it from a Task); the rest are synchronous. Same method set as Android.

Method Signature Notes
startSession func startSession(_ config: SessionConfig) async throws Begins polling + GAM init. Ends any active session first. Throws if the first manifest fetch fails.
endSession func endSession() Stops polling and resets the GAM session.
updateAssetParameters func updateAssetParameters(_ params: [String: String]) Replaces the live-update layer of the asset parameters (GAM + VAST).
updateAssetParameterMacros func updateAssetParameterMacros(_ macros: [String: (() -> String?)?]) Merges the given macros into the session macros (other names stay); nil removes a name so the built-in value applies again. Applies to the next ad request. No-op (logs) without an active session.
updateAssetParameterMacros(values:) func updateAssetParameterMacros(values: [String: (() -> AssetParameterMacroValue)?]) Same merge, with three-state callbacks: .text("…") replaces the token, .empty omits the parameter that contains it (for GAM cust_params only the containing & pair; VAST omits the whole parameter), .unresolved keeps the token literally. A nil entry removes the name.
SessionConfig.assetParameterMacros [String: () -> String?] Customer macros as zero-argument callbacks (a fixed value is a constant callback, { "sports" }); nil counts as unresolved. Built-in names: OptiViewAssetParameterMacros. iOS has no built-in $OPTIVIEW_USER_AGENT$ — register one or the literal token is sent.
SessionConfig.assetParameterMacroValues [String: () -> AssetParameterMacroValue] Three-state customer macros (.text / .empty / .unresolved); an entry here wins over the same name in assetParameterMacros. An omitted parameter raises DA-ASSET-PARAMETER-OMITTED.
play / pause func play() / func pause() No-op while a content-locking break is active.
seek func seek(_ time: Double) Seconds. Blocked during controls.snapback breaks.
destroy func destroy() Tears down the SDK and detaches player observers.
// User starts watching a channel
Task {
    do {
        try await sdk.startSession(
            SessionConfig(
                manifestUrl: "https://manifest.example.com/v1/your-org/channels/d7803a87-465a-4e43-b12e-6f479be119a1",
                customAssetKey: "my-asset-key",
                assetParameters: [
                    "cust_params": "genre=sports",
                    "dimensions": "$OPTIVIEW_PLAYER_WIDTH$x$OPTIVIEW_PLAYER_HEIGHTquot; // macros inside a value → "1920x1080"
                ],
                assetParameterMacros: ["$CUSTOM_TEAMquot;: { currentTeam() }]
            )
        )
        contentPlayer.replaceCurrentItem(with: AVPlayerItem(url: channelUrl))
        contentPlayer.play()
    } catch {
        print("startSession failed:", error)
    }
}

// Update targeting mid-session (no restart)
sdk.updateAssetParameters(["cust_params": "sport=basketball"])
sdk.updateAssetParameterMacros(["$CUSTOM_TEAMquot;: { "lakers" }])

// Switch channel
sdk.endSession()
Task { try await sdk.startSession(SessionConfig(manifestUrl: newManifestUrl, customAssetKey: newKey)) }

// Teardown
sdk.destroy()

Events

Subscribe via sdk.addEventListener(type, handler). All events include a type string and a timestamp (ms since epoch).

All three SDKs expose the same 13 event types (OptiViewAdsEventType) with equivalent payloads — only the subscription idiom differs. The event table below applies to every platform; use the Web / Android / iOS switcher at the top of the sidebar for platform-specific code. volumechange and adchannelchange (below) are currently Web-only — volumechange ships with the Web SDK's unified sdk.muted/sdk.volume audio API and adchannelchange with the manifest-driven channel switch; Android/iOS parity is tracked separately.
Event When Key payload fields
adbreakstatus Break state or countdown changed — upcoming warning, active break progress, completion status — AdBreakStatus object (see below)
adbreakbegin Ad break starts (content paused) break — the full Break object; format — the break's ad format; tuneIn? — present ({ elapsedSec, remainingSec }) only when the viewer joined while the break was already in progress
adbreakend Ad break ends (content resumes) break, format
adclick The viewer activates an ad click-through break, format?, asset, clickThrough? — the host decides whether and how to navigate
adbegin Individual ad starts playing break, format, asset, adIndex, totalAds, adId?, creativeId?
adend Individual ad finishes break, format, asset, adIndex, totalAds, adId?, creativeId?
aderror Playback fails (non-fatal for ads, SDK recovers) source ('ad' | 'content'), error, break?, format?, asset?
adfirstquartile Ad reaches 25% completion break, format, asset, adIndex?, totalAds?
admidpoint Ad reaches 50% completion break, format, asset, adIndex?, totalAds?
adthirdquartile Ad reaches 75% completion break, format, asset, adIndex?, totalAds?
adtimeupdate Fires on each timeupdate tick during ad playback (~4 Hz) break, format, asset, currentTime, duration
waiting Playback stalls for buffering (native waiting) source ('ad' | 'content'), break?, asset?
playing Playback (re)starts after stalling or pausing — fires on every native playing source ('ad' | 'content'), break?, asset?
volumechange (Web only) The SDK's unified mute/volume state changed — via sdk.muted/sdk.volume, a direct mutation of the content/ad element, or an IMA CSAI ad's own UI muted, volume (0-1) — always describe the effective audio owner (ad player during a break, content otherwise)
adchannelchange (Web only) A polled manifest describes a different ads channel and the SDK re-aligned its ad setup to it, while the session and content playback continued previousChannelId, channelId (both string | null), reason ('channel-id-changed' | 'channel-id-added' | 'channel-id-removed' | 'vendor-configuration-changed' | 'timebase-changed'), manifestUrl, previousDeliveryMode, deliveryMode

waiting, playing, and aderror are emitted for both the content player and the SDK-managed ad player. Use the source field to tell them apart; break/asset are only present when source === 'ad'. The source follows the ad, not the player: when an ad plays through the content player (picture-in-picture on iOS and Safari, shared-element/adaptive insertion on iPhone), that player's stalls during the ad are reported with source: 'ad', and as 'content' again once content is handed back.

Channel switch (adchannelchange) — fires once per switch, after the SDK has finished re-aligning to the new manifest (and, when the GAM identity changed, after the new IMA session has been opened or has failed to open). The event includes previousDeliveryMode and deliveryMode. If a break is playing when the switch is detected, that break finishes first: its adend + adbreakend fire, then the latest polled manifest is applied and adchannelchange fires; breaks of the new channel (including its pre-roll) fire their adbreakbegin after it. No DA-SESSION-ENDED/DA-SESSION-STARTED pair is emitted: the session stays active. See Channel switch on the Manifest page for what is reset and what is preserved.
Unified audio (volumechange) — read/write sdk.muted (boolean) and sdk.volume (0-1) to control whichever player is currently audible; the SDK keeps content and ad playback in sync across break transitions in both directions and bridges into IMA for a VAST/CSAI ad in progress. Listen for volumechange to keep a mute button's label correct without polling:
muteButton.onclick = () => {
  sdk.muted = !sdk.muted;
};
sdk.addEventListener('volumechange', (e) => {
  muteButton.textContent = e.muted ? 'Unmute' : 'Mute';
});
Every break/ad-scoped event carries format — the declared format of the break's selected variant (single, double, lshape_ad, lshape_content, overlay; see Ad Formats). It lets you branch on the format (e.g. distinct UI for an L-shape vs a full-screen ad) without re-reading the manifest variant. It is absent only on content-sourced aderror/waiting/playing (no break). Available identically on Web (e.format), Android (e.format: BreakFormat?), and iOS (event.format).
GAM (Google IMA DAI) pod-served ads emit the same balanced lifecycle as any other ad — adbreakbegin → adbegin → adend → adbreakend — and the quartile events (adfirstquartile/admidpoint/adthirdquartile) fire during playout. This relies on the SDK forwarding the ad player's in-stream timed metadata (ID3) to IMA, which is what also drives IMA's own ad-tracking beacons (impressions/quartiles) for monetization — so a working GAM lifecycle and correct beacon firing go together.
A GAM pod holding several creatives reports one ad per creative (Web). A pod is a single vendor asset in the break manifest, but Google may fill it with five or six ads. The SDK asks IMA how the pod was actually filled and emits one adbegin/adend pair per real ad, so adIndex/totalAds describe the pod's true composition (0/6, 1/6, …) instead of claiming 0/1 for the whole pod. Each ad gets its own asset, derived from the manifest pod asset as <podAssetId>-ad-<n> so you can still tell which pod it came from, plus IMA's adId/creativeId when supplied. The quartile events carry the adIndex/totalAds of the ad they belong to, so successive cycles inside one pod are attributable rather than looking like one ad passing its own midpoint repeatedly. adbreakbegin/adbreakend still bracket the pod as a whole, exactly as before.

Because the pod's real composition is only knowable once IMA reports it, the first adbegin waits briefly (up to 3s) for that report rather than announcing a value it would have to contradict. If IMA reports nothing in that window — an unfilled pod, or a runtime whose IMA session receives no timed metadata — the pod is reported as a single ad exactly as it was before, so nothing regresses. Android and iOS still report a pod as one ad (adIndex: 0, totalAds: 1); per-ad reporting there is tracked separately.

One deliberate exception: adtimeupdate stays pod-level. A GAM pod is a single stitched stream, so the ad player's currentTime/duration describe the whole pod, not the creative on screen — its asset therefore remains the manifest pod asset rather than the per-ad one. Drive per-ad progress from the quartile events, and whole-break progress from adbreakstatus.breakRemainingSec.

Break cut short (ad longer than its break) — when a break reaches its maximum duration while an ad is still playing, the SDK hard-cuts back to content but still ends the in-flight ad cleanly: it emits adend (truncated) before adbreakend. So adbegin and adend always balance — every adbegin is followed by exactly one adend, whether the ad ends naturally or is cut. (A break that is cut before any ad begins emits no adend.) Identical on Web, Android, and iOS.

For machine-readable, code-tagged signals (and a shareable exportDiagnostics() report), see Diagnostics.

Web

TypeScript types

// Base — all events extend this
interface OptiViewAdsEvent {
  type: string;
  timestamp: number;
}

// Break events
sdk.addEventListener('adbreakbegin', (e: AdBreakBeginEvent) => {
  // e.break.id, e.break.duration, e.break.start
});

// Ad events — one pair per ad, including each creative of a multi-ad GAM pod
sdk.addEventListener('adbegin', (e: AdBeginEvent) => {
  // e.asset.id, e.asset.type, e.adIndex, e.totalAds, e.format ('single' | 'double' | …)
  // e.adId / e.creativeId — IMA's ids, set only for the ads of a GAM pod
  console.log(`Ad ${e.adIndex + 1} of ${e.totalAds}`);
});

// Quartiles carry adIndex/totalAds when the SDK knows which ad they belong to
// (the ads of a GAM pod), so repeated cycles in one pod stay attributable.
sdk.addEventListener('admidpoint', (e: AdMidpointEvent) => {
  trackMidpoint(e.asset.id, e.adIndex, e.totalAds);
});

// Error events — covers both ad and content playback (check e.source)
sdk.addEventListener('aderror', (e: AdErrorEvent) => {
  console.error(`[${e.source}]`, e.error.message);
  // For ad errors the SDK automatically recovers — content will resume
});

// Buffering / playback state — fires for both content and ad (check e.source)
sdk.addEventListener('waiting', (e: WaitingEvent) => {
  if (e.source === 'content') showSpinner();
});
sdk.addEventListener('playing', (e: PlayingEvent) => {
  if (e.source === 'content') hideSpinner();
});

// Ad progress — track ad position to drive a custom progress bar
sdk.addEventListener('adtimeupdate', (e: AdTimeupdateEvent) => {
  const pct = (e.currentTime / e.duration) * 100;
  progressBar.style.width = `${pct}%`;
});

Unsubscribing

const handler = (e) => console.log(e);
sdk.addEventListener('adbreakbegin', handler);
// Later:
sdk.removeEventListener('adbreakbegin', handler);

Building a custom player UI

Replace the browser's native controls with your own UI so you can:

  • Hide controls during ad breaks and show a countdown toast instead.
  • Route play/pause through the SDK so break policies (content-lock, snapback) are honoured.

Key rules

  • Use sdk.play() / sdk.pause() instead of video.play() / video.pause() — the SDK will no-op these during content-locking breaks, preventing the user from accidentally resuming content mid-break.
  • Use sdk.seek(time) instead of setting video.currentTime directly — honours snapback enforcement. Direct player seeks are also corrected once without recursively re-seeking when the correction emits another seeked event.
  • Drive your countdown from adbreakstatus — the SDK emits this event whenever the break state or countdown changes. Use sdk.getAdBreakStatus() at any time for the current status, or subscribe to adbreakstatus for live updates. The status object includes phase ('idle' | 'upcoming' | 'active' | 'complete'), secondsUntilBreak, breakRemainingSec, adsRemaining, adIndex, totalAds, and ticking. adsRemaining counts the currently playing ad while an ad plays (totalAds − adIndex) and the ads still to come after an adend (totalAds − adIndex − 1, so 0 after the last ad). breakRemainingSec follows the break's media time — for a GAM DAI pod that is the pod's single media timeline, so the countdown runs continuously across the pod's ads.
  • breakWarnings config controls pre-break warnings. Set breakWarnings: { seconds: [10, 5] } to receive adbreakstatus with phase: 'upcoming' at 10s and 5s before the break.
  • Dismiss the countdown on adbreakend — adbreakend fires after all assets in the break have played (or been skipped/errored).

Minimal example

<!-- Remove the native controls attribute -->
<video id="video" muted playsinline></video>

<!-- Custom controls bar (inside the player wrapper) -->
<div id="playerControls" class="player-controls">
  <button id="btnPlay">▶</button>
  <button id="btnMute">🔇</button>
  <button id="btnFullscreen">⤢</button>
</div>

<!-- Break countdown toast (positioned over the player) -->
<div id="breakToast" class="break-toast hidden">
  <span class="toast-badge">AD</span>
  <span id="toastText">Ad break</span>
</div>
sdk.addEventListener('adbreakbegin', (e: AdBreakBeginEvent) => {
  // Hide player controls during the break
  document.getElementById('playerControls')!.classList.remove('visible');
});

sdk.addEventListener('adbreakstatus', (e: AdBreakStatusEvent) => {
  const { status } = e;
  const toast = document.getElementById('breakToast')!;
  const text = document.getElementById('toastText')!;

  if (status.phase === 'upcoming') {
    toast.classList.add('visible');
    text.textContent = `Ad break in ${status.secondsUntilBreak}s`;
    return;
  }

  if (status.phase === 'active') {
    toast.classList.add('visible');
    if (status.ticking && status.breakRemainingSec != null) {
      text.textContent = `Ad break · ${status.breakRemainingSec}s remaining`;
    } else {
      text.textContent = 'Ad break';
    }
    return;
  }

  if (status.phase === 'idle' || status.phase === 'complete') {
    toast.classList.remove('visible');
  }
});

sdk.addEventListener('adbreakend', () => {
  // Dismiss toast and restore controls
  document.getElementById('breakToast')!.classList.remove('visible');
  document.getElementById('playerControls')!.classList.add('visible');
});

// Route play/pause through the SDK
document.getElementById('btnPlay')!.addEventListener('click', () => {
  if (video.paused) sdk.play();
  else sdk.pause();
});

Android (Kotlin)

Subscribe with addEventListener(event: OptiViewAdsEventType, handler: (OptiViewAdsEvent) -> Unit) and unsubscribe with removeEventListener(event, handler). Events are modelled as a sealed class hierarchy — when (event) narrows to the concrete subtype. Note the property is break_ (Kotlin reserves break), and ad-level events carry assetId: String rather than a rich asset object.

import com.dolby.optiview.ads.sdk.*

// Break boundaries
sdk.addEventListener(OptiViewAdsEventType.ADBREAKBEGIN) { event ->
    val e = event as AdBreakBeginEvent
    Log.d("Ads", "Break ${e.break_.id} (${e.break_.duration}s)")
}

// Individual ads
sdk.addEventListener(OptiViewAdsEventType.ADBEGIN) { event ->
    val e = event as AdBeginEvent
    Log.d("Ads", "Ad ${e.adIndex + 1}/${e.totalAds} — asset ${e.assetId} [${e.format?.value}]")
}

// Errors — covers both ad and content (check source); SDK auto-recovers for ads
sdk.addEventListener(OptiViewAdsEventType.ADERROR) { event ->
    val e = event as AdErrorEvent
    Log.e("Ads", "[${e.source.value}] ${e.error.message}")
}

// Buffering / playback state — fires for both content and ad (check source)
sdk.addEventListener(OptiViewAdsEventType.WAITING) { event ->
    if ((event as WaitingEvent).source == PlaybackSource.CONTENT) showSpinner()
}

// Ad progress — drive a custom progress bar
sdk.addEventListener(OptiViewAdsEventType.ADTIMEUPDATE) { event ->
    val e = event as AdTimeupdateEvent
    progressBar.progress = ((e.currentTime / e.duration) * 100).toInt()
}
// Unsubscribing
val handler: OptiViewAdsEventListener = { event -> Log.d("Ads", "$event") }
sdk.addEventListener(OptiViewAdsEventType.ADBREAKBEGIN, handler)
// Later:
sdk.removeEventListener(OptiViewAdsEventType.ADBREAKBEGIN, handler)

iOS / tvOS (Swift)

addEventListener(_:_:) returns a Subscription token; pass it to removeEventListener(_:_:) to unsubscribe. Events are a Swift enum with associated values — switch to read the payload, or use the convenience accessors event.type / event.timestamp / event.source / event.breakId / event.assetId / event.format.

import OptiViewAdsRuntime

// Break boundaries
sdk.addEventListener(.adbreakbegin) { event in
    if case let .adBreakBegin(brk, _, tuneIn, format) = event {
        // `tuneIn` is non-nil when the viewer joined mid-break; `format` is the ad format.
        print("Break \(brk.id) [\(format?.rawValue ?? "?")] (\(brk.duration ?? 0)s)", tuneIn.map { "tune-in, \($0.remainingSec)s left" } ?? "")
    }
}

// Individual ads
sdk.addEventListener(.adbegin) { event in
    if case let .adBegin(_, assetId, adIndex, totalAds, _, format, _, _, _, asset) = event {
        print("Ad \(adIndex + 1)/\(totalAds) — asset \(asset?.id ?? assetId) [\(format?.rawValue ?? "?")]")
    }
    // Or, regardless of case: event.format
}

// Errors — covers both ad and content (check source); SDK auto-recovers for ads
sdk.addEventListener(.aderror) { event in
    if case let .adError(source, _, _, error, _, _) = event {
        print("[\(source.rawValue)] \(error.localizedDescription)")
    }
}

// Buffering / playback state — fires for both content and ad
sdk.addEventListener(.waiting) { event in
    if event.source == .content { showSpinner() }
}

// Ad progress — drive a custom progress bar
sdk.addEventListener(.adtimeupdate) { event in
    if case let .adTimeUpdate(_, _, currentTime, duration, _, _) = event {
        progressView.progress = Float(currentTime / duration)
    }
}
// Unsubscribing — keep the returned token
let token = sdk.addEventListener(.adbreakbegin) { event in print(event.type.rawValue) }
// Later:
sdk.removeEventListener(.adbreakbegin, token)

Diagnostics

The SDK emits a structured diagnostic stream alongside the typed events. Each diagnostic is machine-readable — a stable code, a category, a severity level, and a JSON-serialisable context — so it can be reasoned over by tooling (including the AI troubleshooting assistant).

All three SDKs share the same diagnostic codes, categories, and levels — the codes table further down applies to every platform — and the same redacted exportDiagnostics() report shape. Use the Web / Android / iOS switcher at the top of the sidebar for platform-specific code.

Web

Subscribe to diagnostics

sdk.onDiagnostic((d) => {
  console.log(`[${d.level}] ${d.code} — ${d.message}`, d.context);
});

// later
sdk.offDiagnostic(handler);

A DiagnosticEvent has this shape:

interface DiagnosticEvent {
  ts: number; // epoch ms
  level: 'debug' | 'info' | 'warn' | 'error';
  code: string; // stable, e.g. 'DA-MANIFEST-FETCH-FAILED'
  category:
    | 'manifest'
    | 'session'
    | 'break'
    | 'preload'
    | 'gam'
    | 'playback'
    | 'chaining'
    | 'event'
    | 'lifecycle';
  message: string;
  context?: Record<string, unknown>; // never contains secrets
}

Export a shareable report

exportDiagnostics() returns a self-contained, redacted report — the recent diagnostic + event timeline plus a redacted config summary, the SDK version, and the user agent. It contains no secrets: asset parameter values are stripped (only hasAssetParameters is reported).

const report = sdk.exportDiagnostics();
navigator.clipboard.writeText(JSON.stringify(report, null, 2));

This is the single artifact to paste into a support ticket or feed to the AI troubleshooting assistant.

Configuration

The diagnostic ring buffer that backs exportDiagnostics() is bounded. Tune it via the diagnostics config:

new OptiViewAds({
  // ...
  diagnostics: { bufferSize: 200 }, // default 200; older records are evicted first
});

Diagnostic codes

Codes are part of the public contract and never renamed. The full table (cause + suggested fix per code) is generated from the source taxonomy and published in @dolby-optiview/ads-sdk/ai/reference/error-codes.md.

Code Category Level Meaning
DA-SESSION-STARTED session info Session started; manifest polling began.
DA-SESSION-ENDED session info Session ended; polling and scheduling stopped.
DA-ASSET-PARAMETERS-UPDATED session info Manifest session-level asset parameters changed; merged parameters were pushed to the active GAM stream.
DA-SESSION-STARTING session info A startSession() step was entered (begin, gam-init, manifest-fetch, scheduling); the last one recorded names a hung step.
DA-MANIFEST-FETCH-FAILED manifest error Initial manifest fetch failed; session could not start.
DA-MANIFEST-POLL-FAILED manifest warn A manifest poll failed; last good manifest retained, retrying.
DA-MANIFEST-CHANNEL-CHANGED manifest info A polled manifest describes a different ads channel (channelId, GAM identity or timebase changed); the SDK re-aligned its ad setup and resolved delivery mode again while the session and content continued. context: previousChannelId, channelId, reason, manifestUrl. A matching adchannelchange event with delivery-mode fields is emitted.
DA-DELIVERY-MODE-CHANGE-IGNORED session warn An ordinary poll resolves a different delivery mode; the mode pinned for the session stays in force. Ads-channel switches resolve the mode again.
DA-MANIFEST-INTERCEPT-FAILED manifest warn The interceptManifestResponse hook threw; the un-modified parsed manifest was used instead.
DA-MANIFEST-REQUEST-INTERCEPT-FAILED manifest warn The interceptManifestRequest hook threw; the normal network fetch of the original URL was used instead.
DA-PDT-MISSING playback warn No EXT-X-PROGRAM-DATE-TIME once content is playing (or after the pdtGraceSeconds backstop); wallclock breaks fall back to the clock.
DA-ANVATO-CUE-PENDING break info ptsSource: 'anvatoCue': no Anvato in-stream cue observed yet for a PTS break; the break stays pending.
DA-ANVATO-CUE-MATCHED break info ptsSource: 'anvatoCue': a PTS break was matched to an Anvato cue and scheduled at the cue's media time.
DA-ANVATO-CUE-OBSERVED break info ptsSource: 'anvatoCue': an Anvato break-signaling (type=cue) frame was observed in the stream (context: pts, mediaTime, recorded, raw payload).
DA-PRELOAD-FAILED preload warn Ad preload failed; asset will load on demand at break start.
DA-PRELOAD-MODE-RESOLVED preload info The preload mode in force for the session (context.configured vs context.resolved), once at startup.
DA-PRELOAD-PATH preload info The preload path actually taken per break (context.path) and the creative container (context.creative: stream vs progressive).
DA-BREAK-EARLY-RETURN break info The active break was removed from a polled manifest (early return); the break ends immediately and content resumes at break start plus the seconds already played.
DA-BREAK-SUPPRESSED-OVERLAP break warn A break was suppressed because an ad was already playing.
DA-BREAK-NO-PLAYABLE-ASSET break warn A break with nothing to play never begins: no adbreakbegin/adbreakend, content keeps playing, and an aderror (source ad) + this diagnostic report why (context.reason: no-variant, no-qualifying-variant — device targeting/format playability matched nothing —, no-assets, or lshape-content-skipped; plus context.declaredAssetCount).
DA-BREAK-ABANDONED lifecycle warn The session was torn down while a break was still in flight (context.cause, context.unbalancedAdBegin).
DA-RENDERER-UNAVAILABLE lifecycle error Android: the renderer lacked something it needed (context.missing), so the break began and never ends.
DA-BREAK-FORMAT-DEGRADED break warn A split format needed to reframe the content surface, but the host could not supply the required content or sibling container; rendering may be degraded until attachment succeeds.
DA-BREAK-TRANSITION break info Measured duration of a playback transition into a break, between ads/breaks, or back to content.
DA-PREROLL-CONTENT-HELD break info Content was held at session start until a pending pre-roll began, so no content shows before the ad.
DA-PREROLL-CONTENT-WAIT-TIMEOUT break info A pending pre-roll began after content readiness was not reported within the wait timeout.
DA-PREROLL-CANCELLED-CONTENT-ERROR break warn A pending pre-roll was cancelled because the content failed to load before it could begin.
DA-CHAIN-RESOLVER-ERROR chaining warn The chained-successor resolver threw; chaining skipped.
DA-GAM-NO-ASSET-KEY gam warn GAM configured but no customAssetKey; GAM breaks skipped.
DA-GAM-SESSION-FAILED gam error GAM/IMA session failed to initialize; GAM breaks will not serve.
DA-CONTENT-PLAYBACK-ERROR playback error The content player reported a playback error.
DA-AD-PLAYBACK-ERROR playback error The ad player reported a playback error during a break; context.adErrorCode names the cause (one of the codes below, or DA-AD-PLAYBACK-ERROR when unknown) and context.vendorCode the raw vendor code.
DA-AD-URI-MISSING playback error The ad asset had no usable media URI, VAST tag URL or render surface. Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-AD-MEDIA-LOAD-FAILED playback error The ad player could not fetch the ad media (network or HTTP failure). Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-AD-MEDIA-UNSUPPORTED playback error The ad player could not decode the ad media or does not support its format. Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-AD-START-TIMEOUT playback error The ad did not load, render a first frame or start within its start budget. Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-VAST-NO-FILL gam error The VAST ad server returned no ad (empty response, no ads after wrappers or no assets). Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-VAST-TIMEOUT gam error The VAST response or its media file did not load in time. Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-VAST-MALFORMED gam error The VAST response could not be parsed or uses an unsupported VAST version. Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-VAST-REQUEST-FAILED gam error The VAST ad request failed (network error, invalid tag URL, wrapper error or too many redirects). Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-VAST-CREATIVE-FAILED gam error The VAST creative could not be played (no compatible media file, playback error or trafficking error). Reported as context.adErrorCode and CMCD adec, not as a diagnostic of its own.
DA-AD-START-INTERRUPTED playback warn The ad element's play() was interrupted by a browser-initiated pause before the ad started; the SDK re-issued play() once (context.breakId, context.error).
DA-AD-STALLED playback warn Android: an ad began and then stopped advancing (context.stalledForMs, context.bufferedAheadMs).
DA-AD-FOREGROUND-RESUMED playback info iOS: an in-flight ad resumed after foregrounding (context.suspendedSec, context.resumePositionSec).
DA-AD-FOCUS-GRANTED playback info Android: the overlay ad acquired media focus for the break (context.breakId, context.cause, context.alreadyHeld).
DA-AD-FOCUS-DENIED playback warn Android: the platform refused the overlay ad's media-focus request (context.breakId, context.cause).
DA-AD-FOCUS-ABANDONED playback info Android: the overlay ad released media focus (context.breakId, context.cause).
Emitted SDK events are also captured into the diagnostic timeline under the DA-EVENT code (category event), so a single exported report shows diagnostics and the playback timeline together.

Ad error codes

Every aderror names its cause with one canonical code: DA-AD-URI-MISSING, DA-AD-MEDIA-LOAD-FAILED, DA-AD-MEDIA-UNSUPPORTED and DA-AD-START-TIMEOUT for static creatives; DA-VAST-NO-FILL, DA-VAST-TIMEOUT, DA-VAST-MALFORMED, DA-VAST-REQUEST-FAILED and DA-VAST-CREATIVE-FAILED for VAST; and existing codes such as DA-VAST-IMA-SDK-MISSING, DA-VAST-IMA-ERROR or DA-IMAGE-LOAD-FAILED where they fit. DA-AD-PLAYBACK-ERROR is used only when the cause is unknown. The code appears in the DA-AD-PLAYBACK-ERROR diagnostic as context.adErrorCode and in CMCD as adec. The raw vendor code (for example the IMA error number) stays in context.vendorCode and adevc. Web, Android and iOS map the same failure to the same code.

Diagnosing a playback error

message on a playback error is the player's category, not the cause — media3 reports Source error, and a browser MediaError is barely more specific. The context carries the layer underneath, which is what actually identifies the failure:

field example what it tells you
errorCode 2 (web), -1009 (iOS) the platform's numeric code
errorName MEDIA_ERR_NETWORK the named MediaError code, web only
errorType PlaybackException, NSURLErrorDomain the error's class or domain
cause InvalidResponseCodeException: Response code: 403 the chain beneath it, up to three levels
url https://discovery.example.com/…/main.m3u8 the URL the player was ASKED to open
finalUrl https://cdn.example.com/v2/origin-lon-1/…m3u8 the URL that ANSWERED, after redirects
httpStatus 403 the status, as a number
responseHeaders x-served-by=cache-ams21033-AMS; via=1.1 varnish who answered — see below

cause is normally the decisive one: it distinguishes an HTTP rejection from an unreachable host from a malformed manifest, none of which are separable from the message alone.

url and finalUrl are different questions, and conflating them is a trap: when a discovery or routing service answers 302 with a regional origin, a failure reported against the requested URL says nothing about which hop refused. finalUrl is reported only when it differs, and only where the platform exposes it — web reads the XHR's post-redirect responseURL, iOS reads HTTPURLResponse.url, and Android usually cannot: media3 builds its exception from the original DataSpec and keeps no post-redirect URL, so there the field is honestly absent rather than implied by url.

responseHeaders answers the question that follows a rejection: whose rejection was it? A 403 that the origin's own logs do not show means something in between answered — a proxy, a corporate egress, a CDN edge — and the headers name it (x-served-by and x-cache are Fastly; via and server are usually a proxy). The set is an allowlist, never the whole header block: diagnostics travel off-device into reports and dashboards and must never carry a set-cookie or an authorization. The set covers who answered (x-served-by, via, cf-ray) and why (x-error, cf-ipcountry, retry-after) — a geo rule and a rate limit are indistinguishable without the latter.

Availability differs by platform, and the SDK reports what exists rather than inventing parity: Android surfaces all three (read structurally off the player's HTTP exception, with no ExoPlayer dependency in the core); web surfaces them when the engine reports a network error; iOS usually surfaces only url — AVFoundation reports an opaque error with the HTTP layer buried in an underlying CFNetwork error and exposes no response headers at all.

Measuring transition cost (DA-BREAK-TRANSITION)

A transition is the gap where the viewer sees neither the outgoing nor the incoming media. The SDK times each one and reports it as a DA-BREAK-TRANSITION diagnostic whose context carries:

Field Meaning
transition into-break (break triggered → the break's first ad renders), ad-to-ad (one ad ends → the next ad in the same break renders), break-to-break (a chained break takes over → its first ad renders), out-of-break (break ends → content is playing again).
durationMs Wallclock milliseconds the transition took.
breakId The break the transition led into, or out of.
adPreload The resolved preload mode (parallel / single-decoder) — so measurements are comparable across devices and configurations.
adInsertion The resolved insertion mode.
phases Debug only. Ordered sub-phase checkpoints attributing the transition cost, each { name, atMs, sinceLastMs }. Absent unless the SDK is constructed with debug: true.
sdk.onDiagnostic((d) => {
  if (d.code !== 'DA-BREAK-TRANSITION') return;
  const { transition, durationMs, adPreload } = d.context;
  console.log(`${transition}: ${durationMs}ms (preload: ${adPreload})`);
});

Attributing the cost per phase (debug)

With debug: true, an into-break measurement is broken down so a slow transition can be attributed rather than guessed at:

Phase Meaning
uri-resolved The ad's media URI is known (includes vendor/GAM resolution).
load-start About to attach + load the ad media.
load-resolved Attach + manifest parse finished. sinceLastMs here is the attach/manifest cost.
load-skipped-preloaded Emitted instead of load-start/load-resolved when the break was already preloaded, so no load was needed.
play-called Playback requested. The remaining time to durationMs is first-frame decode/render cost.
first-frame Android and iOS only, and only when a measurement is still open. See DA-AD-SURFACE-REVEALED below, which reports the same moment unconditionally.
sdk.onDiagnostic((d) => {
  if (d.code !== 'DA-BREAK-TRANSITION') return;
  // Where did this transition actually spend its time?
  d.context.phases?.forEach((p) => console.log(`${p.name}: +${p.sinceLastMs}ms`));
});
A transition that never completes (e.g. a break cut short before its first ad rendered, or a format that never pauses content and so never emits a content playing) is dropped rather than reported with a misleading duration — so an absent measurement is itself a signal.

When the ad actually became visible (DA-AD-SURFACE-REVEALED)

Android and iOS hold the ad surface invisible until the ad has really rendered a frame (Media3's onRenderedFirstFrame / AVFoundation's isReadyForDisplay), so a break cross-fades from the content to the ad instead of ramping up to a black surface. This diagnostic reports that moment, once per revealed ad:

Field Meaning
reason first-frame — revealed because the ad had a picture. reveal-timeout — no frame arrived before the ad-start deadline, so it was revealed anyway rather than played invisibly (expected for an audio-only creative; worth investigating otherwise).
waitedMs How long the surface was held back. This is the black gap the viewer would have seen before the gate existed, so it is the number to trend.
transitionMs The fade duration in force, from OptiViewAdsConfig.transition (0 when transitions are off).
assetId / breakId Which ad and break, when known.

It is separate from DA-BREAK-TRANSITION rather than a sub-phase of it because that measurement settles at adbegin — before the first frame — so a phase reported at reveal time would arrive after the measurement closed and be dropped. durationMs is unchanged and still measures commit → playback start.

Android (Kotlin)

onDiagnostic(handler) / offDiagnostic(handler) subscribe to the stream; exportDiagnostics() returns a redacted DiagnosticReport. The codes, categories, and levels are identical to the table above.

val handler: DiagnosticHandler = { d ->
    Log.d("Ads", "[${d.level}] ${d.code} — ${d.message} ${d.context ?: ""}")
}
sdk.onDiagnostic(handler)

// later
sdk.offDiagnostic(handler)

// Shareable, redacted report for a support ticket / AI tooling
val report = sdk.exportDiagnostics()

DiagnosticEvent (com.dolby.optiview.ads.sdk):

data class DiagnosticEvent(
    val ts: Long,                        // epoch ms
    val level: DiagnosticLevel,          // DEBUG | INFO | WARN | ERROR
    val code: String,                    // stable, e.g. "DA-MANIFEST-FETCH-FAILED"
    val category: DiagnosticCategory,    // MANIFEST | SESSION | BREAK | PRELOAD | GAM
                                         // | PLAYBACK | CHAINING | EVENT | LIFECYCLE
    val message: String,
    val context: Map<String, Any?>? = null,   // never contains secrets
)

Tune the ring buffer via OptiViewAdsConfig.diagnostics = DiagnosticsConfig(bufferSize = 200).

iOS / tvOS (Swift)

onDiagnostic(_:) returns a Subscription token (pass it to offDiagnostic(_:)); exportDiagnostics() returns a redacted DiagnosticReport.

let token = sdk.onDiagnostic { d in
    print("[\(d.level)] \(d.code) — \(d.message)", d.context ?? [:])
}

// later
sdk.offDiagnostic(token)

// Shareable, redacted report
let report = sdk.exportDiagnostics()

DiagnosticEvent (OptiViewAdsSDK):

public struct DiagnosticEvent {
    public let ts: Int64                 // epoch ms
    public let level: DiagnosticLevel    // .debug | .info | .warn | .error
    public let code: String              // stable, e.g. "DA-MANIFEST-FETCH-FAILED"
    public let category: DiagnosticCategory  // .manifest | .session | .break | .preload
                                             // | .gam | .playback | .chaining | .event | .lifecycle
    public let message: String
    public let context: [String: Any]?  // never contains secrets
}

Tune the ring buffer via OptiViewAdsConfig(diagnostics: DiagnosticsConfig(bufferSize: 200)).

## Lens observability (built in)

Independently of the diagnostic stream above, the SDK reports to the OptiView Lens SDK on every platform (Web, Android/Android TV, iOS/tvOS, and React Native through those). Lens is a CMCD-based observability layer that Dolby uses to see how ad delivery behaves in the field.

It is internal and always on: there is no configuration option, and nothing about it appears in OptiViewAdsConfig or the public API. Lens joins on the first startSession() and keeps one Lens session per Ads session — so a session you never start reports nothing. On top of the Lens session and heartbeat, the SDK reports CMCD ad events: ad-break and ad start/end, skips, quartiles, clicks and ad errors. Quartiles, clicks and ad errors carry the same ad identity and creative/position dimensions as ad start and end (adpos, adgcrid, adgpid, adasd, adast, adcl when the ad declares them), plus the manifest's optional organizationId/channelId/eventId when present. Every Lens call is guarded: if reporting fails, playback and the ad lifecycle are unaffected (failures show up as debug-level log lines only).

Where the reports land is decided when the SDK is built, not when it runs: release builds (main) report to Dolby's production ingest, while pre-release builds — the -dev artefacts from develop, branch builds, and this demo when it is served from a non-release build — report to Dolby's development ingest, so your evaluation traffic stays out of production telemetry. There is no setting for it either way.

The only integrator-visible consequence today is a packaging one, and only until Lens is published publicly: resolving the Ads SDK needs read access to the private registries listed under Installing the SDK.

Custom Player UI

The SDK is designed to work alongside a fully custom player UI. By replacing the browser's native controls with your own, you get complete control over how playback and ad breaks are presented to users.

The principle is the same on every platform: route playback through the SDK (play/pause/seek), provide an overlay container for the SDK-owned ad surface, and swap your controls for a break indicator on the adbreakbegin/adbreakend events. Use the Web / Android / iOS switcher at the top of the sidebar to choose your platform.

Web

Non-16:9 content and the SDK stage

The SDK creates a pixel-sized .dolby-stage inside the configured container. Once the content <video> reports valid intrinsic dimensions, the stage uses that media aspect ratio rather than assuming 16:9. This matters for edge-anchored overlay, double, and L-shape layouts: their percentages resolve against the painted content rectangle, including after a rendition or source change. If metadata is not available yet, the historical 16:9 geometry remains the safe fallback.

Why custom controls?

Native browser controls have no awareness of ad breaks. A user could hit pause, seek, or unmute at exactly the wrong moment during a break. Routing all interactions through the SDK prevents this:

Action Native With SDK
video.pause() during locked break Pauses content No-op (SDK ignores it)
video.currentTime = x during break Seeks content SDK snaps back if snapback: true
Play button during break Resumes content SDK blocks until break ends

Setup

Remove the controls attribute and position your own UI elements inside the player container:

<!-- The SDK stage -->
<div id="container" style="position:relative;">
  <!--
    Provide an explicit playerContainer that wraps ONLY the content player(s)
    (<video>, and <div id="theoPlayerEl"> if you use THEOplayer). The SDK
    resizes/repositions playerContainer into a pip corner during a
    double/L-shape break — your controls and the break toast must live
    OUTSIDE it, as direct siblings on #container, so they keep spanning the
    full stage instead of shrinking into the pip. If you omit playerContainer
    the SDK auto-wraps ALL of #container's children (including your controls)
    into its own internal wrapper, which has the same shrinking problem.
  -->
  <div id="playerContainer" style="position:absolute;inset:0;">
    <video id="video" muted playsinline></video>
  </div>

  <!-- Controls bar: a sibling of playerContainer, always spans #container -->
  <div id="playerControls" class="player-controls">
    <button id="btnPlay" aria-label="Play/Pause"></button>
    <button id="btnMute" aria-label="Mute/Unmute"></button>
    <button id="btnFullscreen" aria-label="Fullscreen"></button>
  </div>

  <!-- Break toast: also a sibling, so it always covers the full stage -->
  <div id="breakToast" class="break-toast">
    <span class="toast-badge">AD</span>
    <span id="toastText">Ad break</span>
  </div>
</div>

Routing playback through the SDK

Always call sdk.play(), sdk.pause(), and sdk.seek() — never manipulate the video element directly from your UI:

btnPlay.addEventListener('click', () => {
  if (video.paused)
    sdk.play(); // SDK will block if break is content-locking
  else sdk.pause(); // SDK will no-op if break is content-locking
});

// Seek to 3:00 — SDK will snap back if a locked break is active
btnSeek.addEventListener('click', () => sdk.seek(180));

Hiding controls during ad breaks

The SDK fires adbreakbegin and adbreakend when a break starts and ends. Use these to swap your play/seek controls for a break indicator — but keep the mute control reachable: a viewer who cannot unmute a playing pre-roll has no way to hear it. The simplest approach is to only hide the controls that don't apply during a break (play/pause, seek) rather than the whole bar, or to include a Mute button in your break-toast markup too:

sdk.addEventListener('adbreakbegin', () => {
  // Keep the control bar (and its mute button) visible — do not hide it here.
  document.getElementById('breakToast')!.classList.add('visible');
});

sdk.addEventListener('adbreakend', () => {
  document.getElementById('breakToast')!.classList.remove('visible');
});

Break countdown timer

adbreakbegin gives you the total break duration via e.break.duration (seconds, may be undefined for dynamic breaks). Drive a countdown with setInterval:

let countdownTimer: ReturnType<typeof setInterval> | null = null;

sdk.addEventListener('adbreakbegin', (e: AdBreakBeginEvent) => {
  const toastText = document.getElementById('toastText')!;
  let remaining = Math.round(e.break.duration ?? 0);

  const fmt = (s: number) => (s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`);

  toastText.textContent = remaining > 0 ? `Ad break · ${fmt(remaining)} remaining` : 'Ad break';

  if (remaining > 0) {
    countdownTimer = setInterval(() => {
      remaining = Math.max(0, remaining - 1);
      toastText.textContent =
        remaining > 0 ? `Ad break · ${fmt(remaining)} remaining` : 'Ad break ending…';
      if (remaining === 0) {
        clearInterval(countdownTimer!);
        countdownTimer = null;
      }
    }, 1000);
  }
});

sdk.addEventListener('adbreakend', () => {
  clearInterval(countdownTimer!);
  countdownTimer = null;
});

For a more accurate countdown that stays in sync with actual ad playback, use adtimeupdate instead:

sdk.addEventListener('adtimeupdate', (e: AdTimeupdateEvent) => {
  const remaining = Math.ceil(e.duration - e.currentTime);
  document.getElementById('toastText')!.textContent = `Ad break · ${remaining}s remaining`;
});

Ad progress bar

adtimeupdate fires at ~4 Hz during ad playback and includes both currentTime and duration:

sdk.addEventListener('adtimeupdate', (e: AdTimeupdateEvent) => {
  const pct = e.duration > 0 ? (e.currentTime / e.duration) * 100 : 0;
  progressBar.style.width = `${pct}%`;
});

Auto-hiding controls

Show controls on mouse interaction and hide them after a timeout during playback:

let hideTimer: ReturnType<typeof setTimeout> | null = null;

function showControls() {
  controls.classList.add('visible');
  clearTimeout(hideTimer!);
  if (!video.paused) {
    hideTimer = setTimeout(() => controls.classList.remove('visible'), 3000);
  }
}

container.addEventListener('mousemove', showControls);
video.addEventListener('pause', showControls); // keep visible when paused

Fullscreen

Request fullscreen on the SDK container (not the video element) so ad overlays and companion banners are included:

btnFullscreen.addEventListener('click', () => {
  if (!document.fullscreenElement) container.requestFullscreen();
  else document.exitFullscreen();
});

document.addEventListener('fullscreenchange', () => {
  btnFullscreen.setAttribute(
    'aria-label',
    document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen'
  );
});

Mute / volume sync

Drive mute/volume through the SDK's unified sdk.muted / sdk.volume — they always target whichever player is currently audible (the ad player during a break, the content player otherwise), so one button works correctly through break transitions in both directions. Listen for the SDK's volumechange event to keep your icon in sync without polling — it fires on every change, whether triggered by your button, a direct mutation of the content/ad element, or an IMA CSAI ad's own UI:

btnMute.addEventListener('click', () => {
  sdk.muted = !sdk.muted;
});

sdk.addEventListener('volumechange', (e) => {
  btnMute.setAttribute('aria-label', e.muted ? 'Unmute' : 'Mute');
  // swap icon here
});

Current-time indicator

Show the elapsed content time by polling the player on a short interval and formatting currentTime. Reading the player's currentTime works the same way across HLS.js / Shaka / THEOplayer / native, so a single poll drives the label:

const fmt = (seconds: number) => {
  if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
  const t = Math.floor(seconds);
  const h = Math.floor(t / 3600);
  const m = Math.floor((t % 3600) / 60);
  const ss = String(t % 60).padStart(2, '0');
  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
};

const timer = setInterval(() => {
  timeIndicator.textContent = fmt(video.currentTime);
}, 250); // ~4 Hz

// On teardown: clearInterval(timer);

Keep the indicator inside #playerControls so it auto-hides during ad breaks alongside the rest of your controls (the break toast covers the ad). The demo factors this into a shared formatPlayerTime() / createTimeIndicator() helper used by every demo page.

Skip forward / back

Add skip buttons that jump the content timeline by a fixed offset. Route them through sdk.seek() and clamp the target to the playable range so you never seek past the end (or before the start). Because the seek goes through the SDK, it is a no-op without an active session and is suppressed during snapback breaks:

const clampSeekTarget = (current: number, delta: number, duration: number) => {
  const target = (Number.isFinite(current) ? current : 0) + delta;
  if (target < 0) return 0;
  if (Number.isFinite(duration) && target > duration) return duration; // live: skip upper clamp
  return target;
};

function skip(delta: number) {
  sdk.seek(clampSeekTarget(video.currentTime, delta, video.duration));
}

btnBack15.addEventListener('click', () => skip(-15));
btnFwd30.addEventListener('click', () => skip(30));

The demo's VOD page wires exactly this (−15s / +30s) with the shared clampSeekTarget() helper in packages/demo/src/seek.ts. The buttons live inside #playerControls, so they auto-hide during ad breaks.

Remembered session fields

The demo's Player page remembers your per-user Org ID, Channel ID, Custom Asset Key, Content URL, and Ad Tag Parameters in localStorage, so edits survive a reload. The built-in defaults stay as the fallback for a fresh browser, and a Reset to defaults link under the Load button clears the saved values and restores them. This is a demo-page convenience (helper in packages/demo/src/field-storage.ts); the SDK itself stores nothing. URL query-param overrides used by the e2e suite still take precedence over stored values.

Predefined configurations via URL (?presets)

The Player page can be seeded with named, fully-formed configurations passed in the URL — handy for sharing reproducible setups without hand-editing the form. Pass a base64-encoded JSON array of configuration objects in the ?presets= query parameter. When present, a Predefined Configurations card appears at the top of the config column; choosing an entry fills the whole form and auto-loads it. The first entry is auto-selected and loaded on page open. With no ?presets parameter, the card is hidden and the page behaves exactly as before (the manual form stays available either way).

Every field on the page is configurable. All fields except name are optional — an omitted field keeps the form's current/default value. The one exception is preRoll: it is a full on/off toggle, so a preset that omits preRoll (or sets "enabled": false) turns pre-roll OFF. This means switching from a pre-roll preset to one without never inherits the previous pre-roll injection.

[
  {
    "name": "Sponsor A — SGAI DAR", // required, non-empty; the selector label
    "mode": "sgai", // "sgai" | "ssai"
    "player": "hlsjs", // "hlsjs" | "shaka" | "theo" | "native"
    "manifestBaseUrl": "https://us.staging.ads.sneezysparrow.com/manifest/v1",
    "gam": true, // Enable GAM
    "networkCode": "23285652104",
    "orgId": "0bda787b-…",
    "channelId": "d7803a87-…",
    "customAssetKey": "assetkey_michel",
    "adInsertionType": "replacement", // "replacement" (DAR) | "insertion" (DAI)
    "contentUrl": "https://…/main.m3u8",
    "assetParams": { "iu": "/23285652104/dcoffey-v2-adunit" }, // object or JSON string
    "preRoll": { "enabled": true, "format": "single", "delaySeconds": 0 },
    "stitcherBaseUrl": "http://localhost:4600", // SSAI only — demo composes the master URL from it + orgId/channelId
    "stitcherOriginUrl": "https://…/main.m3u8",
    "maxBitrate": 2000000,
    "ssaiAutoplay": true,
    "stitcherPort": "4600",
    "snapPolicy": "nearest", // "nearest" | "start" | "end"
  },
]

Build the parameter by base64-encoding the JSON array (standard or URL-safe base64 is accepted), e.g. in the browser console:

const presets = [{ name: 'Sponsor A', orgId: '…', channelId: '…' }];
location.search = '?presets=' + encodeURIComponent(btoa(JSON.stringify(presets)));

Unknown keys are ignored and unknown enum values (mode, player, insertion type, snap policy, pre-roll format) are skipped with a logged warning, so a config is forward-compatible. A malformed, empty, or non-array ?presets value is ignored (with one warning in the event log) and the card is not shown. Decoding/applying is handled by the unit-tested packages/demo/src/demo-presets.ts helper (decodeUrlPresets / applyPreset); demo only — no SDK/core change.

Preset Builder

You don't have to hand-write that base64 array. The Preset Builder page (sidebar → Preset Builder, /preset-builder.html) lets you visually assemble one or more presets, generates the shareable URL-safe ?presets= link and the raw JSON live, and offers Copy + Open in Player. You can also paste an existing ?presets URL (or base64 token) to import and edit it — a full round-trip. The builder reuses the same demo-presets.ts primitives (encodeUrlPresetsSafe / decodeUrlPresets) and the pure packages/demo/src/preset-builder-model.ts helper (buildPreset / presetToValues / presetsToUrl / urlToPresets), so the links it produces are exactly what the Player page consumes.

Android (Kotlin)

The SDK plays ads on its own PlayerView inside the overlayContainer FrameLayout you pass to OverlayAdRenderer, stacked above your content surface. Build your own controls (or use a Compose overlay) and follow the same rules:

  • Provide an overlay container above your content PlayerView — a FrameLayout matching the content bounds, passed to OverlayAdRenderer(context, overlayContainer, contentAdapter).
  • Route playback through the SDK — call sdk.play() / sdk.pause() / sdk.seek(seconds) instead of touching the ExoPlayer directly, so content-lock and snapback are honoured.
  • Swap controls for a break indicator on ADBREAKBEGIN / ADBREAKEND.
  • Drive a countdown from the ADTIMEUPDATE event (currentTime / duration) or from break_.duration.
// Hide your controls + show a break indicator during the break
sdk.addEventListener(OptiViewAdsEventType.ADBREAKBEGIN) {
    playerControls.isVisible = false
    breakToast.isVisible = true
}
sdk.addEventListener(OptiViewAdsEventType.ADBREAKEND) {
    breakToast.isVisible = false
    playerControls.isVisible = true
}

// Countdown that stays in sync with actual ad playback
sdk.addEventListener(OptiViewAdsEventType.ADTIMEUPDATE) { event ->
    val e = event as AdTimeupdateEvent
    val remaining = kotlin.math.ceil(e.duration - e.currentTime).toInt()
    toastText.text = "Ad break · ${remaining}s remaining"
}

// Route the play/pause button through the SDK (no-op during content-locking breaks)
playPauseButton.setOnClickListener {
    if (contentPlayer.isPlaying) sdk.pause() else sdk.play()
}
Mute/volume are synced between the content and ad players by the SDK; just update your own icon. Never hand your content ExoPlayer to the renderer — the SDK owns a separate ad player.

iOS / tvOS (Swift)

The SDK plays ads on its own AVPlayer in an AVPlayerLayer inside the overlayContainer UIView you pass to OverlayAdRenderer, stacked above your content surface. The same rules apply:

  • Provide an overlay container above your content surface — a UIView matching the content bounds, passed to OverlayAdRenderer(overlayContainer:contentPlayer:).
  • Route playback through the SDK — call sdk.play() / sdk.pause() / sdk.seek(seconds) instead of touching the AVPlayer directly.
  • Swap controls for a break indicator on .adbreakbegin / .adbreakend.
  • Drive a countdown from the .adtimeupdate event, or from the break duration.
// Hide your controls + show a break indicator during the break
sdk.addEventListener(.adbreakbegin) { _ in
    playerControls.isHidden = true
    breakToast.isHidden = false
}
sdk.addEventListener(.adbreakend) { _ in
    breakToast.isHidden = true
    playerControls.isHidden = false
}

// Countdown that stays in sync with actual ad playback
sdk.addEventListener(.adtimeupdate) { event in
    if case let .adTimeUpdate(_, _, currentTime, duration, _) = event {
        let remaining = Int(ceil(duration - currentTime))
        toastLabel.text = "Ad break · \(remaining)s remaining"
    }
}

// Route the play/pause button through the SDK (no-op during content-locking breaks)
playPauseButton.addAction(UIAction { _ in
    contentPlayer.timeControlStatus == .playing ? sdk.pause() : sdk.play()
}, for: .touchUpInside)
On iPhone the SDK may resolve to shared-element / adaptive insertion (a single fullscreen ad through the content player) to preserve iOS native fullscreen — see SDK Configuration. tvOS and iPad use the overlay compositor.

Ad Formats

Each break in the manifest carries a variant that specifies the format. The SDK applies the correct layout automatically and controls whether content playback is paused.

Format Content paused? Companion? Description
single ✅ Yes — Full-screen ad overlay. Content is hidden and paused.
double ❌ No¹ ✅ Image (picture-relative artwork) Content and ad play side-by-side in equal boxes (920×517.5px at 1080p, 20px border). Companion artwork follows the painted picture while native hosts fill letterbox bars opaquely. Animated transition in/out. ¹On handheld/tablet web devices the split layout cannot be read, so double renders as a single fullscreen ad with content paused (platform-capability fallback); events still report double.
lshape_ad ✅ Yes ✅ Image (picture-relative artwork) Content player is hidden. Ad video plays in a pip at the top-left quadrant (5% inset). Companion artwork follows the painted picture while native hosts fill letterbox bars opaquely. Animated transition in/out.
lshape_content ❌ No — Content player shrinks to a pip at the top-left quadrant — content keeps playing. A backdrop image (from assets[0]) follows the painted picture while its host fills letterbox bars opaquely. No ad video plays. Animated transition in/out.
overlay ❌ No — Semi-transparent ad positioned over content using manifest-supplied position and size values (fractional 0.0–1.0, converted to %). Content keeps playing.
The above are client-side (SGAI) layouts. The server-side SSAI Stitcher always stitches a fullscreen single ad, and by default also stitches double and lshape_ad as fullscreen single (their companion is dropped) — set stitchCompositedAsSingle: false to stitch only genuine single breaks. lshape_content and overlay are never stitched.

Manifest variant shapes

single / lshape_content / overlay — simple assets array

{
  "format": "single",
  "assets": [
    { "id": "a1", "type": "static", "mediaType": "video", "uri": "https://cdn.example.com/ad.m3u8" }
  ]
}

double / lshape_ad — assets with companion (flat)

The companion is a property directly on the asset object (flat intersection, not nested).

{
  "format": "double",
  "assets": [
    {
      "id": "a1",
      "type": "static",
      "mediaType": "video",
      "uri": "https://cdn.example.com/ad.m3u8",
      "companion": {
        "id": "c1",
        "type": "static",
        "mediaType": "image",
        "uri": "https://cdn.example.com/companion.jpg"
      }
    }
  ]
}

overlay — position + size (fractional 0.0–1.0)

{
  "format": "overlay",
  "assets": ["..."],
  "position": { "bottom": 0.05, "right": 0.05 },
  "size": { "width": 0.3, "height": 0.2 },
  "opacity": 0.9
}

Image (non-linear) overlay assets

An overlay asset can be an image instead of a video by setting mediaType: "image" (e.g. a banner or logo bug). The SDK renders it as an <img> (web) / ImageView (Android) / UIImageView (iOS) inside the overlay's position/size/opacity box, and holds it on screen for the asset's duration (seconds). When duration is omitted, the break duration is used as the fallback.

{
  "format": "overlay",
  "assets": [
    {
      "id": "a1",
      "type": "static",
      "mediaType": "image",
      "uri": "https://cdn.example.com/banner.png",
      "duration": 8
    }
  ],
  "position": { "bottom": 0.05, "right": 0.05 },
  "size": { "width": 0.3, "height": 0.2 },
  "opacity": 0.9
}
  • Preload — image overlay assets are warmed (decoded) ahead of the break, so they appear instantly with no flash.
  • Failure handling — if an image fails to load, the SDK dispatches aderror for that asset and ignores it (no adbegin); any remaining assets in the break still play, and content keeps playing throughout (overlay never pauses content).
  • Native parity — image overlay rendering, preload, and failure handling behave identically on web, Android, and iOS.

Layout DOM structure

At initialize() the SDK permanently sets playerContainer to position:absolute; top:0; left:0; right:0; bottom:0 with a 0.3s ease-in-out transition on all position axes, enabling smooth animated transitions between formats. At break end the container animates back to the base position. On destroy() the original styles are fully restored.

Format playerContainer at break start .dolby-ad-container .dolby-companion
single base (full stage) top:0; left:0; right:0; bottom:0 —
double top:26.04%; left:1.04%; right:51.04%; bottom:26.04% (left box) top:26.04%; left:51.04%; right:1.04%; bottom:26.04% (right box) Picture-relative artwork, opaque host fill, z-index 99
lshape_ad top:5%; left:5%; right:30%; bottom:30% then opacity:0 (content scales to pip before hiding) top:5%; left:5%; right:30%; bottom:30% pip, z-index 100 (ad plays here) Picture-relative artwork, opaque host fill, z-index 99
lshape_content top:5%; left:5%; right:30%; bottom:30% pip, z-index 101 (content plays here) Hidden (display:none) Backdrop from assets[0], picture-relative artwork, opaque host fill, z-index 99
overlay base (full stage) Manifest fractional position/size → CSS % —

Break Manifest

The break manifest is a JSON document served by the OptiView Ads backend. The SDK fetches it at session start and polls it at the intervals specified in the manifest.

Manifest URL

GET https://<your-manifest-host>/manifest/v1/{orgId}/channels/{channelId}

# The SDK does not build this URL. Since ADS-222 you pass the finished URL to
# startSession({ manifestUrl }) and it is fetched and polled verbatim, so the
# path shape above is the manifest service's contract, not the SDK's.

Structure overview

{
  "version": "1.0.0",
  "timebase": "wallclock",
  "polling": {
    "idle": 30,
    "active": 5
  },
  "breaks": [
    {
      "id": "break-001",
      "start": "2026-06-07T14:00:00Z",
      "duration": 30,
      "variant": {
        "format": "single",
        "assets": [
          {
            "id": "asset-001",
            "type": "vendor",
            "vendor": "gam",
            "mediaType": "video",
            "uri": "pod-id-from-eabn",
            "vendorParameters": {
              "type": "pod",
              "eabnVersion": "V2",
              "networkCode": "23285652104",
              "customAssetKey": "my-asset-key"
            }
          }
        ]
      }
    }
  ]
}

Manifest signature

Every manifest response is authenticated before it is parsed: the server signs the response body and sends a detached JWS (Ed25519, RFC 7515 Appendix F + RFC 7797 b64:false) in the X-Manifest-Signature header, which the SDK verifies against an embedded trusted key list. Verification fails closed — a missing header, unknown key id, unsupported algorithm, or tampered body rejects the manifest (DA-MANIFEST-SIGNATURE-INVALID) and schedules no breaks; a failed poll keeps the last verified manifest and retries at the next interval. Two hosting requirements:

  • the manifest host must expose the header to browsers: Access-Control-Expose-Headers: X-Manifest-Signature;
  • the body must reach the SDK byte-identical to what was signed (any rewriting proxy invalidates the signature).

The internal Web demo/tests additionally trust a repo-public test key, and the demo's local manifest server signs with it. Every published Android and iOS/tvOS SDK—release, dev build, or custom—trusts only the three backend keys; their unit/conformance tests inject the test key through non-production wiring. Mock responses returned by interceptManifestRequest bypass the network and are not signature-checked.

Polling

The manifest's polling object is the only source of the refresh cadence — there is no client-side default and no configuration override:

  • polling.idle — seconds between re-fetches while no ad break is active.
  • polling.active — seconds between re-fetches while a break is playing. The SDK switches the running timer between the two intervals the moment the break-active state changes.
  • No polling object — the manifest is fetched exactly once at session start and never re-polled (typical for VOD).

A polled manifest may itself change or remove its polling object; the SDK re-resolves the cadence after every poll.

Delivery steering (delivery + vendorConfiguration)

The manifest's optional delivery array lets the backend steer which ad-insertion architecture a session uses, per platform:

{
  "delivery": [
    { "mode": "ssai", "targeting": { "deviceType": "tv" } },
    { "mode": "sgai" }
  ],
  "vendorConfiguration": [
    {
      "type": "gam",
      "networkCode": "23285652104",
      "customAssetKey": "pod-serving-key",
      "assetKey": "full-service-dai-key"
    }
  ]
}
  • Rules are evaluated in order against the client's deviceType (desktop / mobile / tablet / tv); the first matching targeted rule wins, and a rule without targeting is the default. Absent delivery or no matching rule resolves sgai (the current behaviour). An unrecognized mode value resolves sgai and emits DA-DELIVERY-MODE-UNKNOWN (forward compatibility).
  • The mode is resolved on the first manifest fetch and again on an ads-channel switch (DA-DELIVERY-MODE-RESOLVED). Ordinary polls keep the current mode and log DA-DELIVERY-MODE-CHANGE-IGNORED when the rules differ. Everything else in a polled manifest (breaks, polling, vendor configuration) is honored normally.
  • An explicit constructor mode overrides manifest steering (DA-DELIVERY-MODE-OVERRIDDEN) — explicit client configuration always wins.
  • Manifest-steered ssai requires a vendor configuration able to provide the stitched stream — currently a gam entry carrying an assetKey (the channel's full-service DAI livestream identifier, distinct from the pod-serving customAssetKey). The SDK requests the stitched livestream via IMA (LiveStreamRequest) and plays it on the content player; timebased breaks are carried by the stream. The SDK keeps polling the manifest and still schedules event-triggered breaks (pre-roll, pause ads) client-side.
  • When ssai is selected but no vendor configuration provides a stitched stream, startSession rejects with the catchable SsaiUnavailableError (DA-SSAI-UNAVAILABLE) — there is no silent fallback to sgai; the application decides which stream to play.
  • Platform support: web and Android / Android TV (including React Native Android, where the explicit override is the JS config's mode). On Android the failure is the equivalent SsaiUnavailableException. iOS / tvOS still resolves every session as sgai; steering lands with its SSAI backfill.

Ad-free start (adStartDelay)

An optional top-level adStartDelay (seconds >= 0, next to polling; omitted means 0) keeps the start of a session ad-free. It counts played media time only, from the first frame of the session — pausing or seeking does not advance it, so viewers cannot fast-forward through the ad-free window.

When a manifest channel switch is applied before playback starts, the new manifest's adStartDelay replaces the previous value and still counts from the first frame of the session. After playback starts, a channel switch does not update the delay in effect.

While the delay has not elapsed, no break may start and controls.snapback does not apply. Once it elapses, the SDK looks at the current position and resumes normal behavior:

  • If the position is inside a break, the SDK tunes in to the remainder of that break (resume point per resumeOffset or the break duration).
  • Breaks whose start passed during the delay are not started retroactively, but they are not consumed either — seeking over one with snapback enabled afterwards snaps back to it, and seeking back into the DVR window and reaching one plays it as normally scheduled.
  • It applies to timebase-scheduled and event-triggered breaks alike, judged on the break's effective start moment (trigger plus the break's own delay). A pre-roll therefore plays only when its own delay >= adStartDelay (both count played media time from session start); an immediate pre-roll under an ad-free start is intentionally skipped.

This supports players initialized speculatively (e.g. while scrolling through a feed): ads only show once the viewer has actually watched for a while.

Try it: the Manifests page has an Ad-free start (adStartDelay 20 s) preset with one mid-roll before the delay elapses (passed, unconsumed) and one after it (plays normally). Create it as a channel, play it on the Player page, and pause or seek during the first 20 s to see the delay hold.

Break removal (early return)

When a break is removed from a polled manifest while it is playing, the SDK ends the break immediately (the balanced adend + adbreakend still fire) and resumes content at break.start plus the seconds the break already played — including in replacement mode, where a completed break normally does not seek. A chained successor is deliberately not started early; it still triggers at its own scheduled start. The removal surfaces as the DA-BREAK-EARLY-RETURN diagnostic.

Channel switch

A successful poll can describe a different ads channel than the manifest the session was started with. The SDK detects this when, compared with the previous manifest:

  • the top-level channelId is added, removed or changed (reason: channel-id-added, channel-id-removed, channel-id-changed);
  • the GAM pod identity (networkCode/customAssetKey) or the stitched-stream vendor identity changes while channelId stays the same (reason: vendor-configuration-changed);
  • the timebase changes while channelId stays the same (reason: timebase-changed).

The SDK then re-aligns its ad setup to the new manifest without ending the session: content playback continues, startSession()/endSession() are not needed, and no DA-SESSION-ENDED/DA-SESSION-STARTED is emitted. The delivery mode is resolved again. The switch surfaces as the DA-MANIFEST-CHANNEL-CHANGED diagnostic and, once the re-alignment is complete, as the adchannelchange event (previousChannelId, channelId, reason, manifestUrl, previousDeliveryMode, deliveryMode; see Events).

What is reset:

  • Pending breaks, completed-break history and retired breaks of the old channel are dropped. A break id that already played on the old channel may play again on the new one.
  • Preload and break-warning state, and the CMCD/Lens reporting sessions, restart for the new channel.
  • When the GAM pod identity changed, the IMA stream session is closed and re-opened with the new identity (DA-GAM-SESSION-FAILED is emitted if the new session cannot be opened; the switch still completes).

What is preserved:

  • The parent content session, the content player and its playback position.
  • The startup assetParameters/assetParameterMacros and any runtime updateAssetParameters() overrides; both are re-applied to a re-opened GAM session.
  • The delivery mode is resolved again from the new manifest. An sgai to ssai switch loads the stitched stream into the player. If no stitched stream is available, DA-SSAI-UNAVAILABLE is logged and the session stays in sgai. An ssai to sgai switch stops the stitched stream; the SDK does not load a content stream, so the application must load it after adchannelchange (previousDeliveryMode: 'ssai', deliveryMode: 'sgai'). Explicit constructor mode still wins.

Runtime updateAssetParameters() overrides made while in steered ssai are not carried into the sgai GAM pod session after ssai to sgai; startup parameters/macros and sgai-side runtime overrides are used.

Active break rule: a break that is playing when the poll arrives finishes its playout first. The switch takes effect when that break ends; the break is not aborted and it is not carried over as a break of the new channel. If more manifests are polled while the break plays, the latest one is applied at that moment.

Pre-roll and adStartDelay: the new channel's pre-roll plays. Its delay counts played media time from the moment of the switch, not from session start. While playback has not started yet, the adStartDelay of the latest manifest applies and counts from the first frame. Once playback has started, a switch no longer changes the adStartDelay in effect.

A poll that returns the same channel (same channelId, vendor identity and timebase) is an ordinary update: breaks, polling and the other fields are merged as described above and no adchannelchange fires.

Timebase

Value break.start type Matching strategy
wallclock ISO-8601 string Compared against the stream's EXT-X-PROGRAM-DATE-TIME via programDateTime.
pts number (seconds) Live streams. Compared against player.currentTime, or resolved through an Anvato cue (ptsSource).
mediatime number (seconds) VOD only. Seconds from the start of the asset, compared against player.currentTime. Never uses cues.

Pre-roll breaks

A pre-roll is a break whose start is an event trigger for the start event, as defined by the Ads Manifest Specification (1.1.0+). It fires when content playback begins instead of at a timebase-derived position. An optional delay (seconds, default 0) is the amount of played media time before the pre-roll fires: paused time and seek jumps do not advance it.

{
  "id": "preroll-1",
  "start": { "type": "event", "event": "start", "delay": 0 },
  "duration": 15,
  "variant": { "format": "single", "assets": [{ "..." }] }
}
Field Type Default Description
start.type "event" — Declares an event-triggered break.
start.event "start" — Content playback start. This is the pre-roll trigger.
start.delay number (seconds) 0 Played media time after playback starts before the pre-roll triggers.

Pre-roll rules, on every platform (web, Android, iOS, React Native):

  • A pre-roll fires once per session. Seeking back to the start, or replaying content, does not trigger it again.
  • An immediate pre-roll (delay 0) holds content until the ad starts (see Session). A delayed pre-roll starts counting its delay when the first content frame plays, not when the session is created.
  • adStartDelay applies: a pre-roll plays only when its delay >= adStartDelay.
  • resumeOffset applies; controls.snapback is ignored for event-triggered breaks.
  • The start and end events are scheduled on every platform (see Post-roll breaks); the pause event drives pause ads. A break whose start.event is an unknown value is ignored and reported once as DA-BREAK-EVENT-UNSUPPORTED.

Note: Pre-rolls are only supported in SGAI mode (client-side insertion).

Legacy shape: the SDK still accepts position: "pre" with a top-level delay (and a placeholder start: 0). Both shapes are scheduled identically. New manifests should use the event trigger above.

Try it (load a published channel): the VOD page has a Break source toggle. Leave it on Break manifest (JSON) to paste/edit a manifest (the demo spins up the local server and provisions it), or switch to Channel ID to load a channel another tool already published to the ad-break server — the SDK is pointed at the configured manifest server directly (no local server, no POST). Only the channel ID is required; the hostname (default https://ads-sdk.xnappet.live) and org ID are pre-filled under Advanced, and pasting a full channel URL auto-fills all three.

Try it: the demo has a dedicated Pre-roll page (sidebar → Pre-roll). The player selector shows the bundled HLS.js, Shaka and THEOplayer versions, the content URL accepts any reachable HTTP(S) HLS stream, and the player panel shows its current program date time in ISO-8601 UTC for wall-clock scheduling (Unavailable until the adapter receives a valid EXT-X-PROGRAM-DATE-TIME). In Generated manifest mode, Run starts the local manifest server, creates a pre-roll channel using the selected ad experience (single, double-box, L-shape ad, L-shape content or overlay), and switches between immediate and 5-second-delayed playback with the delay checkbox. In Custom URL mode, the page skips local channel creation and passes the supplied full, signed manifest URL directly to the SDK. The page loads the Google IMA client-side SDK (ima3.js) so custom manifests can contain VAST/CSAI assets. Remote content and manifest servers must permit browser CORS requests. Requires the Vite dev server (npm run dev).

Try it (main Player page, real backend): the Player page talks to the real ad-manifest backend, which does not serve pre-roll yet. Click Configure Pre-roll… (under the Session card) to open a modal where you can enable pre-roll, pick the ad format, and set a delay in seconds (0 = immediate). When enabled, the demo augments the backend break manifest client-side via the SDK's interceptManifestResponse hook: the callback receives the parsed, validated BreakManifest (on the initial fetch and every poll) and prepends a pre-roll break (start: { "type": "event", "event": "start" }), so the pre-roll plays at session start while every other break still comes from the backend. This is a demo-only convenience (the modal carries the same warning); leave it disabled to use the backend manifest verbatim. SGAI only — it has no effect in SSAI mode.

Post-roll breaks

A post-roll is a break whose start is an event trigger for the end event, as defined by the Ads Manifest Specification (1.3.0+). It fires when content playback ends (the player's ended event) instead of at a timebase-derived position. An optional delay (seconds, default 0) is the wallclock time between the end of content and the start of the break.

{
  "id": "postroll-1",
  "start": { "type": "event", "event": "end", "delay": 0 },
  "duration": 15,
  "variant": { "format": "single", "assets": [{ "..." }] }
}
Field Type Default Description
start.type "event" — Declares an event-triggered break.
start.event "end" — Content playback end. This is the post-roll trigger.
start.delay number (seconds) 0 Wallclock time after playback ends before the post-roll triggers.

Post-roll rules:

  • A post-roll fires once per session. Replaying the content after the post-roll, or a manifest re-poll, does not trigger it again.
  • The delay starts counting when content playback ends. When the viewer replays the content before the delay elapses, the pending post-roll is cancelled. It is armed again the next time content ends.
  • adStartDelay applies, judged on the effective start moment (content end plus delay): a post-roll is not started while the ad-free start is still active.
  • duration is the maximum duration of the break. Without controls.skipOffset the break is watched fully; with it the viewer can end the break once the offset is reached.
  • resumeOffset and controls.snapback are ignored. After the break, the content stays at its end position and is not restarted: the SDK does not seek or call play(). In shared-element mode the content source is restored on the element at its end position and left paused.
  • Event-triggered breaks and timebase breaks can be mixed in one manifest. In manifest-steered ssai mode, post-rolls are still scheduled client-side.

Note: Post-rolls are only supported in SGAI mode (client-side insertion). They are scheduled on every platform (web, Android, iOS/tvOS, React Native).

Asset types

type Description
static A direct HLS URL. SDK loads it into the ad player as-is.
vendor (gam) A GAM pod ID. SDK builds a DAI pod manifest URL using the IMA stream session.
vast A VAST ad tag URL. The SDK fetches, parses, and plays it client-side (CSAI) via the Google IMA SDK (ima3.js). Web, Android, and iOS support landed. SGAI-only and linear-only (single/double/lshape_ad) — unsupported placements are skipped with a DA-VAST-* diagnostic. See VAST (CSAI).

vendor and (since Break Manifest 1.4.0) vast assets may carry assetParameters, a string map the SDK forwards to the ad server — as IMA ad-tag parameters for GAM, as query parameters on the tag URL for VAST. $NAME$ tokens inside a value are macros, replaced in place ("$OPTIVIEW_PLAYER_WIDTH$x$OPTIVIEW_PLAYER_HEIGHTquot; → "1920x1080"): the SDK fills in $OPTIVIEW_USER_AGENT$, $OPTIVIEW_PLAYER_WIDTH$, $OPTIVIEW_PLAYER_HEIGHT$, $OPTIVIEW_BREAK_DURATION_MS$ and $OPTIVIEW_BREAK_DURATION_SECONDS$, and the app can add or override names with SessionConfig.assetParameterMacros. A string replaces a token. null marks it empty, so the parameter is not sent; for GAM cust_params, only the &-separated pair containing the macro is dropped, and the key is omitted if no pair remains. For VAST, the whole cust_params parameter is omitted. An unresolved value (undefined) leaves the token literal and still sends the parameter. A value with an unresolved $OPTIVIEW_* name, or a registered customer macro without a value, is kept literally and sent with DA-ASSET-MACRO-UNKNOWN raised once per parameter key and macro; a null omission raises DA-ASSET-PARAMETER-OMITTED. Any other unresolved complete token is kept literally without a diagnostic. A $ without a closing $ is left unchanged. $APP$ and $APP_NAME$ never collide. See the Session page for the full rules and the GAM page for how the session, update, manifest-session and per-asset layers combine.

Try it: the Manifests page has a VAST — CSAI (IMA sample tag) preset that builds a vast pre-roll using Google's public IMA sample tag. Create it as a channel, then play that channel on the Player page to watch a client-side VAST ad. See the VAST (CSAI) page for the full walkthrough.

Creative hosting requirements (CORS). Every asset uri — static media, VAST tag XML and its referenced media files, and companion/pause-ad images — is fetched directly by the browser (or, for vast, by the Google IMA SDK from its own iframe), so the hosting server must send permissive CORS headers (and answer HEAD, which some ad-tech CDNs skip). A non-CORS host fails with MEDIA_ELEMENT_ERROR: Format error (static/vast media) or a silent tag fetch failure (VAST XML) — easy to mistake for a manifest or SDK bug. If a creative host cannot be made CORS-compliant, proxy it through your own origin. All demo sample creatives (SAMPLE_AD_MP4, SAMPLE_AD_VAST, SAMPLE_COMPANION_IMG, etc.) are pre-verified CORS-enabled hosts.

Variant selection (device targeting + format fallbacks)

A break may declare several variants, and the SDK picks ONE with a single ordered walk over them (identical on web, Android, and iOS — locked by the cross-language conformance harness):

  1. Device-type match. A variant qualifies when its targeting.deviceType matches the detected device class (desktop / mobile / tablet / tv — the same detection used for per-URI asset targeting). A variant without targeting is a default and qualifies on any device.
  2. Format playability. A qualifying variant whose format the platform cannot render in the current context is passed over in favor of the next qualifying variant (e.g. authoring [double, single] gives platforms that cannot show a two-box layout the single fallback). A variant with an unknown format is never playable. While content is in picture-in-picture at break start the playable set narrows to single, so a declared single variant is picked over overriding a rich one.

Declaration order wins whenever several variants qualify — both for multiple defaults and for multiple variants targeting the same device type.

PiP after preload (Web): if inline playback preloads double asset ad1 and PiP entry before break start selects a declared single variant with asset ad2, playback loads ad2 rather than reusing ad1. An earlier request for ad1 is legitimate when PiP was entered after preload; it does not mean ad1 plays. Attached media is reused only once and only when the break ID, asset ID, resolved URI and owning adapter instance match. These web-runtime cache rules do not change the variant-selection policy above or cover IMA/VAST manager reuse.

When nothing qualifies (and no downgrade path applies), the break does not begin: no adbreakbegin/adbreakend is emitted, content keeps playing, and the SDK reports an aderror event (source: 'ad', carrying the break) plus the DA-BREAK-NO-PLAYABLE-ASSET diagnostic naming the reason (no-qualifying-variant). The same no-begin contract now applies to every break with nothing to play (empty asset lists included) — previously such breaks emitted a misleading empty adbreakbegin/adbreakend pair.

Try it: on the Manifests page, author a break whose variant is an array — e.g. a tv-targeted double first and an untargeted single behind it — and play it on the Player page: a desktop browser plays the single, and a break whose only variant targets tv never begins (watch the aderror

  • DA-BREAK-NO-PLAYABLE-ASSET in the event log).
The full manifest specification — including break variants, asset targeting, forward compatibility rules, and JSON schema — is documented in the OptiView Ads manifest spec (v1.4.0).

Manifest interception

Two optional SGAI hooks let you intercept the break manifest on the client — without standing up a proxy — at the two natural points around the fetch. Both run on the initial fetch and every subsequent poll.

  • interceptManifestRequest runs before the network request (rewrite URL, add headers, or mock the response).
  • interceptManifestResponse runs after fetch + validation (transform the parsed BreakManifest).

Request interception (interceptManifestRequest)

The interceptManifestRequest hook is invoked before the SDK fetches the manifest, so you can redirect the request, attach headers, or short-circuit the network entirely with a mocked response. It is primarily a testing aid (point the SDK at a fixture, or exercise header-based auth) but is a supported production hook.

Return one of:

  • a ManifestRequest ({ url, headers? }) — fetch this (possibly rewritten) URL with the given headers;
  • a ManifestMockResponse ({ body }) — skip the network and use body (a raw JSON string or object) as the fetched manifest body. The mock body goes through the normal parse + validation path — an invalid body still fails validation, and interceptManifestResponse (if set) still runs afterward;
  • nothing (void/undefined) — fetch unchanged.

Failure is non-fatal: if the hook throws (or its promise rejects), the SDK emits DA-MANIFEST-REQUEST-INTERCEPT-FAILED and falls back to the normal network fetch of the original URL. May be async.

import { OptiViewAds, type ManifestRequest } from '@dolby-optiview/ads-sdk-core';

const sdk = new OptiViewAds({
  // ...
  // Add an auth header to every manifest request (initial fetch + every poll):
  interceptManifestRequest: (request: ManifestRequest) => ({
    ...request,
    headers: { ...request.headers, Authorization: `Bearer ${token}` },
  }),
});

// Or short-circuit the network with a fixture (great for tests):
new OptiViewAds({
  // ...
  interceptManifestRequest: () => ({ body: JSON.stringify(myFixtureManifest) }),
});
SGAI only. The same API exists on Android ((suspend (ManifestRequest, ManifestRequestContext) -> ManifestRequestResult?)?) and iOS (ManifestRequestInterceptor? returning .request/.mock/nil); both forward it to the injected ManifestSource, which applies it before the network fetch.

Response interception (interceptManifestResponse)

Some integrations need to adjust the break manifest on the client — inject a pre-roll, drop a break, rewrite an asset URL, or layer in client-side targeting — without standing up a proxy. The optional interceptManifestResponse SDK config hook is invoked after the SDK has fetched and validated the manifest, on the initial fetch and on every subsequent poll, so you can transform it before it is scheduled.

Key guarantees:

  • Typed, never raw. The callback receives the parsed BreakManifest (normalized variants arrays, parsed numbers/enums) — never the raw JSON. An invalid manifest fails validation before the hook runs, so it is never called with garbage.
  • Its return value is used as-is. Return the (possibly new) BreakManifest to schedule; it is used directly, without re-validation. Returning the input unchanged is a no-op.
  • Runs on every poll. Live channels re-poll; the hook is applied each time, so keep it pure/idempotent (e.g. guard against injecting the same break twice).
  • Failure is non-fatal. If the hook throws (or its promise rejects), the SDK emits the DA-MANIFEST-INTERCEPT-FAILED diagnostic and falls back to the unmodified parsed manifest — the fetch/poll does not fail.
  • May be async. Return a Promise<BreakManifest> to do async work (the poll awaits it).
import { OptiViewAds, type BreakManifest } from '@dolby-optiview/ads-sdk-core';

const sdk = new OptiViewAds({
  // ...
  interceptManifestResponse: (manifest: BreakManifest, ctx) => {
    // Inject a client-side pre-roll, idempotently (runs on every poll).
    if (manifest.breaks.some((b) => b.id === 'my-preroll')) return manifest;
    return {
      ...manifest,
      breaks: [
        {
          id: 'my-preroll',
          start: { type: 'event', event: 'start' },
          duration: 15,
          variants: [
            /* ... */
          ],
        },
        ...manifest.breaks,
      ],
    };
  },
});
SGAI only. The demo's Pre-roll feature on the Player page is built on this hook. The same API exists on Android ((suspend (BreakManifest, ManifestInterceptContext) -> BreakManifest)?) and iOS (ManifestResponseInterceptor?); both forward it to the injected ManifestSource, which applies it after parsing. See the per-platform config tables under SDK Configuration.

Google Ad Manager

The SDK integrates with Google DAI Pod Serving via the IMA DAI SDK. When a GAM vendor asset is scheduled, the SDK requests a pod manifest URL and plays it in the ad player.

This page shows the web API. GAM/DAI pod serving ships on the native Android and iOS SDKs too (over the platform IMA SDK). See Android and iOS / tvOS.

Playback and event timing

On Web, THEOplayer metadata tracks are enabled as hidden so presented TXXX markers reach IMA. The SDK reports IMA's actual per-ad quartiles; it does not replace missing markers with percentages of the entire pod. The break-cut countdown starts on actual ad playback, so manifest loading and decoder startup do not shorten the playback allowance. A separate startup watchdog still returns to content if the ad never starts.

Prerequisites

  1. A Google Ad Manager account with DAI Pod Serving enabled.
  2. A Network Code and a Custom Asset Key per channel.
  3. Include the IMA DAI SDK in your HTML before your application script:
<script src="https://imasdk.googleapis.com/js/sdkloader/ima3_dai.js"></script>

Configuration

const sdk = new OptiViewAds({
  player: contentAdapter,
  container: document.getElementById('container'),
  playerContainer: document.getElementById('playerContainer'),
  createAdAdapter: (adVideo) => buildAdAdapter(adVideo),
  gam: {
    networkCode: '23285652104', // your GAM network code
  },
});

await sdk.startSession({
  manifestUrl: 'https://manifest.example.com/v1/your-org/channels/your-channel',
  customAssetKey: 'your-asset-key', // per-channel key
  assetParameters: {
    ott_placement: '0', // 0 = single / full-screen
    cust_params: 'region=eu', // custom targeting
  },
});

Asset parameters

Four sources can describe the parameters for one GAM asset, ordered by how specific they are. Later layers override earlier ones per key, so overriding one key never discards the rest:

  1. The session — SessionConfig.assetParameters, supplied at startSession().
  2. A live update — updateAssetParameters() or updateAssetParameterMacros().
  3. The manifest session — vendorConfiguration.gam.sgai[0].assetParameters.
  4. The asset — asset.assetParameters, the targeting the break author wrote for that specific asset.

The deprecated top-level customAssetKey and assetKey remain supported; nested sgai[0] and ssai[0] fields are preferred in manifest spec 1.4.0.

To change parameters during an active session (e.g. a content segment change):

sdk.updateAssetParameters({ cust_params: 'genre=basketball' });
sdk.updateAssetParameterMacros({ '$CUSTOM_TEAM#x27;: 'basketball' });

This calls IMA's StreamManager.replaceAdTagParameters() under the hood — no session restart needed. Two consequences follow from how that API behaves:

  • It applies to future breaks only. A break whose ad request has already gone out cannot be retargeted.
  • It replaces the whole map rather than merging, so the SDK always pushes the full effective map for the asset about to play, and pushes the session baseline back when the break ends. Without that restore one asset's targeting would leak into every later break.

Values that contain full $MACRO$ tokens are substituted before the map is pushed — see Asset-parameter macros on the Session page. A $ without a closing $ is left unchanged, and $APP$ and $APP_NAME$ never collide. The same four layers and macros also apply to vast assets, where the effective map is appended to the tag URL (not to inline data: tag URLs, which have no query component) (see VAST (CSAI)).

OTT placement values

Value Placement
0 Single (standard full-screen)
1 Pause screen
3 Picture-in-Picture / double box
4 L-banner
5 Overlay

How pod serving works

When a GAM break is detected in the manifest, the SDK:

  1. Uses the IMA StreamManager (initialized at startSession()) to build a pod manifest URL from the stream ID and pod ID.
  2. Loads that URL into the ad player (preloaded 5 seconds before break start).
  3. Pauses content and plays the ad overlay at break time.
  4. Forwards IMA metadata events for ad tracking (quartile beacons, etc.).

The pod stream carries its ad markers as ID3 timed metadata, and IMA only learns where each ad starts and ends — and only fires its tracking beacons — from the markers the SDK pushes to it. When the pod plays through the content player instead of the ad player (picture-in-picture on iOS and Safari, the shared-element and adaptive insertion modes on iPhone), the SDK pushes that player's markers for the duration of the break, so Google tracking and IMA's ad events continue. On iOS this also keeps the per-ad adbegin/adend expansion of a multi-ad pod; on the web the shared-element path still reports a pod as one ad.

This page describes SGAI (client-side) pod serving, where the player builds the pod URL and composites the ad. The same GAM pod serving can also run server-side via the SSAI Stitcher: the client still creates the DAI stream and tracks, but the server requests the pod and bakes it into the HLS stream (reusing the same pod-URL builder).

AI Assistance

Explain it like I'm 2. The SDK comes with a little robot helper. Type one magic line — npx dolby-ads-init-ai — and the robot drops helper notes inside your code editor. Now when you ask your editor's AI "help me connect my video player," it already knows exactly how. And when something breaks, you click Export Report, paste it to the AI, and the robot tells you what went wrong and how to fix it.

The SDK ships AI integration artifacts so your own IDE assistant (Claude Code, Windsurf, Copilot + AGENTS.md, …) can help you get started, implement a custom PlayerAdapter, and troubleshoot from SDK diagnostics. We ship the knowledge and tools — the LLM is your editor's own assistant. Nothing is sent to Dolby.

What you get

Artifact What it does Where it lives
Onboarding agent Tutor that explains the model step by step, shows where to start, then offers to bootstrap a demo. @dolby-optiview/ads-sdk → ai/agents/dolby-onboarding/AGENT.md
Quickstart skill Collects a few inputs and bootstraps a runnable single-file demo — with or without MCP. @dolby-optiview/ads-sdk → ai/skills/dolby-quickstart-demo/SKILL.md
Adapter skill Bounded instructions to scaffold + validate a PlayerAdapter. @dolby-optiview/ads-sdk → ai/skills/dolby-adapter-integration/SKILL.md
Troubleshooter agent Diagnostic-reasoning instructions for reading a report. @dolby-optiview/ads-sdk → ai/agents/dolby-troubleshooter/AGENT.md
Features overview DAI vs DAR, SGAI vs SSAI, VAST, pre-roll, break formats, adapters. ai/reference/features-overview.md
PlayerAdapter contract Machine-readable interface spec. ai/reference/playeradapter.contract.md
Error codes Generated table of every diagnostic code. ai/reference/error-codes.md
Knowledge base Code → cause → fix mapping. ai/reference/knowledge-base.md
Native overview Orientation for the Android/iOS SDKs and how they map to the web API. ai/reference/native-overview.md
MCP server (optional) Executable troubleshooting + scaffolding tools (@dolby-optiview/ads-sdk-mcp, installed from its artefact-host tarball — see Install). dolby-ads-mcp

Step 1 — Install the artifacts into your project

npx dolby-ads-init-ai            # copies into ./.dolby-ads/ai
npx dolby-ads-init-ai docs/ai    # or a directory you choose
npx dolby-ads-init-ai --force    # overwrite existing files

This copies the skill, agent, and reference files from the installed @dolby-optiview/ads-sdk package into your repo so your AI IDE can pick them up. Point your assistant at the copied folder (e.g. reference it from AGENTS.md, CLAUDE.md, or .github/copilot-instructions.md). Existing files are skipped unless you pass --force.

The same copy logic is exported programmatically as runInitAi(options) from @dolby-optiview/ads-sdk if you prefer to script it.

Step 2 — (Optional) Add the MCP server

The MCP server is optional. The shipped *.md artifacts are sufficient on their own — the MCP server is an accelerator that provides structured tools.

@dolby-optiview/ads-sdk-mcp is a Model Context Protocol stdio server. It runs locally; diagnostics never leave your machine. It is not published to npmjs: install its tarball from the artefact host (see the Install page, Web table) — npm install -g <tarball URL> puts the dolby-ads-mcp binary on your PATH — then register it with your MCP-capable client:

{
  "mcpServers": {
    "dolby-ads": {
      "command": "dolby-ads-mcp"
    }
  }
}

It exposes eight tools:

Tool Input Returns
analyze_diagnostics an exportDiagnostics() report severity tallies, ranked findings with remediation, timeline analysis, and a root-cause hint
lookup_error_code a code like DA-PDT-MISSING (+ the report's sdkVersion) category, severity, summary, remediation — unknown codes return a hint: check the spelling, or upgrade @dolby-optiview/ads-sdk-mcp when the report comes from a newer SDK than the bundled taxonomy snapshot
explain_event_timeline a report or event array the playback (DA-EVENT) sequence, session-level observations, and a per-break breakdown (breaks[]: lifecycle, ad counts, quartiles, anomalies)
validate_break_manifest a break manifest (object or raw JSON string) { valid, errors, warnings } with a JSON path per issue — spec violations the SDK rejects, plus authoring smells (overlapping breaks, unknown enum values, missing companions)
resolve_config a device userAgent plus adPreload / adInsertion / mode (all optional) { devices[], issues[] } — the deviceType, resolvedPreload, and resolvedInsertion that will actually run, with a reason per choice; omit userAgent for the full preset device matrix
scaffold_quickstart manifestUrl, player, contentUrl (+ optional environment, gam, mode, stitcherUrl) browser players: a self-contained index.html demo; React Native players (react-native-theoplayer, react-native-video): drop-in files for an existing new-architecture RN app (OptiViewAdsQuickstart.tsx + OPTIVIEW_ADS_SETUP.md); native players (android-media3, ios-avplayer): OptiViewAdsQuickstart.kt / .swift + OPTIVIEW_ADS_SETUP.md — plus notes and warnings
explain_concept a concept, feature or contract question (+ optional limit) { matches[], relatedCodes[], suggestions[] } — the matching sections of the SDK's conceptual reference verbatim, most relevant first, plus the diagnostic codes worth watching for that concept
decode_cue an in-stream cue: base64/hex SCTE-35, a pasted HLS tag line, a base64 ID3 tag or bare GEOB body, an emsg payload, or type=cue&pts=… (+ optional kind, schemeIdUri, manifestPts) the decoded cue — SCTE-35 command, PTS (pts_adjustment applied, 90 kHz → seconds), break duration, segmentation descriptors and CRC check; or the Anvato frame fields and payload params — plus a ptsCheck against the manifest break start (offset in ms vs the SDK's 5 ms tolerance and the verdict the scheduler would reach)

Step 3 — Bootstrap a demo

New to the SDK? Point your assistant at the onboarding agent (ai/agents/dolby-onboarding/AGENT.md). It walks you through the mental model (content player + PlayerAdapter, the SDK-managed ad overlay, manifest + programDateTime timebase, the session lifecycle, and the diagnostics timeline), shows where to start, then asks if you want to try it. If you say yes, it follows the quickstart skill — which includes a canonical template so it works without the MCP server. If the MCP server is installed, it may call scaffold_quickstart to auto-pin the SDK version. The generated index.html uses ESM CDN imports — open it directly or serve the folder.

You can also try it without an assistant using the Get started card below, which calls scaffold_quickstart fully client-side.

mode picks the insertion topology and defaults to sgai. With mode: 'ssai' the scaffold takes the full stitcherUrl (the stitcher master playlist, https://host/ssai/v1/{orgId}/{channelId}/master.m3u8) and wires startSession({ stitcherUrl, customAssetKey }) with mode: 'ssai' plus gam.networkCode — no break-manifest polling and no ad overlay, and the scaffold deliberately never loads the content itself, because the SDK creates the IMA DAI stream and loads the stitched master into your player. SSAI orchestration is web-only today, so asking for it with a React Native or native player scaffolds SGAI instead and says so in the warnings.

The native players emit a drop-in file for an existing app — a Media3 / ExoPlayer Activity or an AVPlayer UIViewController — plus a setup guide with dependencies, platform requirements, GAM wiring and teardown. The native package coordinates are still preview and not yet published, which the tool warns about.

Step 3b — Ask what a feature actually is

The conceptual reference (features overview, the PlayerAdapter contract, the native and React Native overviews) ships as files inside @dolby-optiview/ads-sdk/ai, so an assistant that only has the MCP server cannot read it. explain_concept embeds a snapshot of those documents and answers questions against it — "what is break chaining", "tune-in", "L-shape", "DAI vs DAR", "SGAI vs SSAI", "what must a custom PlayerAdapter implement", "which host players does React Native support".

Sections come back verbatim (most relevant first, limit defaults to 3), so the answer cannot drift from the docs, and vocabulary that is not a literal heading (chaining, l-shape, stitcher, custom player, DAR, CSAI, kotlin…) resolves through an alias table. Each answer also carries relatedCodes — the diagnostic codes worth watching for that concept, which you can feed straight into lookup_error_code. For a specific DA-* code, use lookup_error_code directly.

Step 3c — Decode an in-stream cue

When a break does not start on a cue-signalled stream, the question is always whether the in-stream cue and the manifest break agree on the media timeline. decode_cue decodes whatever you have in hand and answers it:

  • a SCTE-35 splice_info_section (base64 or hex) — command, the 33-bit PTS with pts_adjustment applied and converted from 90 kHz ticks to seconds, out-of-network / immediate / auto-return flags, break duration, segmentation descriptors (type name, UPID) and the CRC_32 check;
  • a pasted HLS tag line — #EXT-X-DATERANGE:…,SCTE35-OUT=0xFC30… has its binary section extracted and decoded; #EXT-X-CUE-OUT:38.4 carries only a duration, which the tool says rather than failing;
  • an Anvato ID3 GEOB cue, either a whole ID3 tag (hls.js) or the bare GEOB body (Shaka), an emsg identified by its schemeIdUri, or a bare type=cue&pts=1234.567 payload.

Add manifestPts (the break's start, in seconds) and the answer includes the offset in milliseconds against the SDK's 5 ms PTS matching tolerance plus the verdict the scheduler would reach — which is exactly what the DA-ANVATO-CUE-PENDING / -OBSERVED remediations ask you to compare. Malformed blobs come back as warnings, never as an error, and nothing is fetched: the decode is entirely local.

Step 4 — Troubleshoot

  1. Reproduce the issue with the SDK running.
  2. Capture a redacted report: const report = sdk.exportDiagnostics(); (no ad-tag-parameter values or secrets are ever included — see Diagnostics).
  3. Hand the report to your AI assistant ("analyze these OptiView Ads diagnostics") — with the MCP server registered it calls analyze_diagnostics; otherwise the pasted JSON plus the bundled knowledge base is enough.
  4. You get a root cause and concrete fix steps.
  5. If the report looks healthy but the ad behaved oddly on a specific device (a black frame on a CTV, an insertion mode that seems ignored), ask for resolve_config with that device's User-Agent and your adPreload / adInsertion / mode values: auto resolves per device and some explicit values are overridden, so the resolved modes are often the whole story.

You can try the exact same analysis, fully client-side, in the AI Troubleshooting card on the demo player — Export Report, AI Analyze, and Check Adapter.

Step 4b — File a bug report straight from the demo

The demo has a dedicated File a Bug page in the sidebar. As you use the Player page the SDK's redacted exportDiagnostics() report is captured automatically (kept in the tab's sessionStorage); the File a Bug page reads that snapshot, runs the analyze_diagnostics root-cause/findings over it, and lets you enter a title, what happened, and repro steps. Open Jira to file opens a pre-filled Create issue screen for the PLAYG project in a new tab — you review and click Create. It is client-only: no backend and no token, so no secrets ever leave the page. Because URLs are length-bounded, attach the full redacted report with Copy full report / Download JSON when the pre-filled diagnostics are trimmed.

Step 5 — Implement a custom adapter

Ask your assistant to follow the adapter skill, then prove the result with the shared conformance suite (@dolby-optiview/ads-sdk-adapter-test-kit) — see Validate your adapter. The contract the assistant follows is ai/reference/playeradapter.contract.md, mirroring the PlayerAdapter Interface.

Step 6 — Explore and implement features

Your assistant doesn't just explain concepts — it helps you put them into practice. Ask it anything and it will guide you through the relevant code, config, and trade-offs:

  • "Which ad insertion mode should I use?" — it walks you through SGAI (client-scheduled, the default) vs SSAI (server-stitched, single stream) vs DAR (dynamic ad replacement), and helps you pick the right one for your workflow.
  • "How do I add VAST ads?" — it explains how VAST / CSAI tags are fetched and played client-side via Google IMA, which break formats are supported (single, double, lshape_ad), and helps you wire up the IMA SDK.
  • "Can I schedule pre-roll, mid-roll, and post-roll?" — it shows you how break timing works with programDateTime, how the break manifest drives scheduling, and how to configure pre-roll vs mid-roll placement.
  • "What break formats are available?" — it explains single, double, lshape_ad, lshape_content, and overlay formats, their layout rules, and when to use each.
  • "Which player can I use?" — it helps you choose between HLS.js, Shaka, THEOplayer, native HLS (Safari/iOS), or a custom adapter, and can scaffold the integration for you (see Step 5).
  • "What about advanced behaviours?" — it can explain and help you implement tune-in (waiting for a live edge before playing ads), chaining (back-to-back breaks), and overlap suppression (preventing duplicate ad breaks at the same timecode).

The assistant reads ai/reference/features-overview.md for accurate, up-to-date answers — so you can ask in plain language and get SDK-specific guidance, not generic advice.

We intentionally ship knowledge and tools, not an LLM. Your IDE's own assistant does the reasoning, so your code and diagnostics stay with you.

SSAI Stitcher (server-side)

The OptiView Ads SDK supports two ad-insertion topologies:

  • SGAI (client-side, default): the player composites the ad over content using the SDK's insertion modes (overlay / shared-element / adaptive).
  • SSAI (server-side): the @dolby-optiview/ads-sdk-stitcher server bakes the ad into the HLS stream the player receives. The player just plays one stitched stream.

This page summarizes the stitcher. The authoritative contract is packages/stitcher/CONTRACT.md; the design overview is docs/ssai-stitcher.md.

There are two SSAI entry points. This page covers explicit SSAI (mode: 'ssai' + startSession({ stitcherUrl }), using the @dolby-optiview/ads-sdk-stitcher service). The break manifest can also steer a session to SSAI via its delivery rules — that path plays the channel's full-service GAM DAI livestream (the gam vendor configuration's assetKey), needs no stitcher service, keeps polling the manifest and still renders event-triggered breaks (pause ads) client-side. See Delivery steering on the Break Manifest page. An explicit constructor mode always overrides manifest steering.

When to use SSAI

  • Devices/players where client-side compositing is hard or undesirable.
  • You want ads baked in at the CDN/edge and a thin client.
  • You only need fullscreen linear ads — SSAI always stitches a fullscreen single ad. By default it also stitches double and lshape_ad as fullscreen single (companion dropped); lshape_content and overlay need client compositing and are never stitched. See Supported formats below.

How it works (hybrid DAI)

The stitcher is a stateless HLS manifest transform — it rewrites playlists and references ad-segment URLs; it never proxies video bytes.

  1. The client creates the Google DAI stream (IMA PodStreamRequest) and gets a streamId, then plays the stitcher's master URL. The client's IMA SDK does ad tracking by reading the timed metadata the stitcher leaves in the stitched segments.
  2. The stitcher owns the break manifest (fetched server-side) and the content origin (per-channel preconfigured). For each eligible break it snaps to a segment boundary, builds the DAI pod URL with the same buildGamPodUrl the client SDKs use (passing the client's streamId), and splices the pod's segments in — wrapped in EXT-X-DISCONTINUITY.

Supported formats

Format Stitched?
single Always — fullscreen ad.
double By default (companion dropped). Disable with stitchCompositedAsSingle: false.
lshape_ad By default (companion dropped). Disable with stitchCompositedAsSingle: false.
lshape_content Never (backdrop image, no fullscreen ad video).
overlay Never (content keeps playing; needs client compositing).

stitchCompositedAsSingle is a per-channel server setting (server-wide default true), not a client/URL parameter. double and lshape_ad both carry a real fullscreen ad video, so they degrade cleanly to a fullscreen single splice with the companion discarded.

URL contract (summary)

GET /ssai/v1/{orgId}/{channelId}/master.m3u8?stream_id={sid}&max_bitrate={bps}
# (the stitcher's own route shape — the SDK takes the finished master URL as
#  startSession({ stitcherUrl }) and only appends stream_id/max_bitrate)
GET /ssai/v1/{orgId}/{channelId}/media/{variantId}.m3u8?stream_id={sid}
  • stream_id — the DAI session token from the client's IMA stream (required for gam breaks; absent → those breaks are skipped).
  • max_bitrate — optional (bits/sec, master only). Drops master variants whose BANDWIDTH exceeds it; fail-open keeps the lowest variant if none qualify.

Boundary snap policy

Breaks rarely line up exactly with a segment boundary, so the stitcher snaps each break to one according to a per-channel snapPolicy (server default nearest):

Policy Behavior
start Boundary at or before the break start (round down).
end Boundary at or after the break start (round up).
nearest Closest boundary; ties round down.

Resulting timing skew is at most one segment. It is an operator/per-channel setting, not a client/URL parameter.

Guarantees

  • Fail-open: if the break manifest, pod, or parameters are unusable, the player receives the unmodified content — playback never breaks.
  • Timebase: pts / mediatime (cumulative EXTINF) and wallclock (EXT-X-PROGRAM-DATE-TIME) are all supported.
  • Ad duration: the pod is trimmed to the break duration (whole-segment) and never overshoots into content.

Client SDK integration (SSAI mode)

Set mode: 'ssai' on the SDK config. In this mode the SDK does not poll a break manifest, schedule breaks, or run an ad-player overlay. Instead it creates the IMA DAI stream, builds the stitcher master URL, loads it into the content player, forwards in-stream timedmetadata to IMA, and re-emits the normal SDK ad-event stream.

import { OptiViewAds } from '@dolby-optiview/ads-sdk-core';
import { HlsJsAdapter } from '@dolby-optiview/ads-sdk-adapter-hlsjs';

const sdk = new OptiViewAds({
  mode: 'ssai',
  player: new HlsJsAdapter(hls, video), // must expose a videoElement (IMA binds to it)
  gam: { networkCode: '23285652104' },
  maxBitrate: 2000000, // optional → max_bitrate on the master URL (caps rendition bitrate)
  autoplay: true, // optional → SDK plays the stitched stream after load
});

// No content URL load by the app: startSession loads the stitched master itself.
// stitcherUrl is the stitcher's master-playlist route for this channel; the SDK
// appends stream_id (and max_bitrate) to it and never composes the path itself.
await sdk.startSession({
  stitcherUrl: 'https://stitch.example.com/ssai/v1/your-org/your-channel/master.m3u8',
  customAssetKey: 'asset-key',
});

Requirements and notes:

  • The content PlayerAdapter must expose videoElement (IMA DAI binds to a real <video>). HLS.js, Shaka, and the native-video adapter qualify.
  • mode: 'ssai' requires gam.networkCode; startSession requires stitcherUrl (the full master-playlist URL, in place of manifestUrl) and customAssetKey. The demo composes that URL from its Stitcher Base URL field plus the org/channel pickers.
  • customAssetKey/assetParameters come from startSession for explicit SSAI. Manifest-steered SSAI also takes ssai[0].assetParameters as the manifest session layer. The precedence is session < update < manifest; a manifest parameter cannot be overridden by updateAssetParameters().
  • Failures surface as DA-SSAI-SESSION-FAILED (startup) and DA-SSAI-IMA-ERROR (in-stream); ad lifecycle is emitted as the usual adbreakbegin / adbegin / quartiles / adend / adbreakend events.

Parity: the portable buildStitcherMasterUrl is locked across the TS, Kotlin, and Swift cores by the conformance harness. The SSAI orchestration (SsaiController) is currently web-only and tracked as a parity gap for the native cores (conformance/PENDING-PARITY.md).

Trying it in this demo

The demo's SDK Config card has an Ad Insertion Architecture toggle. The Session card (Org ID, Channel ID, Custom Asset Key, Content URL, Ad Tag Parameters) is shared by both modes. Pick SSAI (stitched) to reveal the SSAI Stitcher (dev) card, then:

  1. Click Start Stitcher — a Vite dev middleware spawns a local @dolby-optiview/ads-sdk-stitcher configured with a single channel built from the Org ID, Channel ID, GAM Network Code, and the Stitcher Origin URL (which defaults to the Session Content URL but can be set independently — some operators ingest a different origin for SSAI). The Stitcher Base URL is filled in for you.
  2. (optional) Set Max Bitrate to cap the rendition bitrate — the stitcher drops higher variants from the master playlist.
  3. Click Load — the SDK creates the IMA stream, loads the stitched master, and (with Autoplay on) plays it.
  4. Click Stop Stitcher when done to shut the dev server down.

Break Manifest Server

The @dolby-optiview/ads-sdk-break-manifest-server package provides a standalone, lightweight server that stores and serves static break manifests. It exposes a production-compatible URL schema so the SDK can poll manifests from it without modification.

Use Cases

  • Local development — test ad breaks without depending on the production API.
  • Demo / QA — reproduce specific break schedules with known manifests.
  • Integration tests — programmatically create and tear down manifest endpoints.

Quick Start

# Start the server with your org ID
npx dolby-ads-manifest-server --org-id <your-org-id> --port 4100

The server is now running at http://localhost:4100.

API

The URL schema mirrors the production OptiView Ads manifest API:

/manifest/v1/{org-id}/channels/{channel-id}

POST /manifest/v1/:orgId/channels

Create a new manifest endpoint.

Body: a valid BreakManifest JSON object (see the manifest spec).

Response (201):

{
  "channelId": "d7803a87-465a-4e43-b12e-6f479be119a1",
  "url": "http://localhost:4100/manifest/v1/0bda787b.../channels/d7803a87..."
}

The returned url can be used directly as the SDK manifest URL.

GET /manifest/v1/:orgId/channels/:channelId

Retrieve a stored break manifest. This is the URL the SDK polls.

GET /manifest/v1/:orgId/channels

List all stored channel IDs and their URLs.

DELETE /manifest/v1/:orgId/channels/:channelId

Remove a stored manifest endpoint.

GET /version

Returns the SDK version: { "version": "0.17.0" }.

CLI Options

Option Env var Default Description
--org-id ORG_ID — Required. The org ID to serve.
--port PORT 4100 Port to listen on.

Demo Page Integration

The demo page includes a Manifests section in the sidebar that provides a UI for managing manifest endpoints:

  1. Enter the server base URL and org ID.
  2. Click Start Server to launch the server via the Vite dev proxy.
  3. Paste a break manifest JSON and click Create Endpoint.
  4. The created URL is displayed and can be used in the player configuration.
  5. Existing endpoints are listed with delete buttons.

Programmatic Use

import { createBreakManifestApp } from '@dolby-optiview/ads-sdk-break-manifest-server';

const app = createBreakManifestApp({
  port: 4100,
});

app.listen(4100);

Installing the SDK

Public distribution

Releases use the OptiView name and a lockstep version. Web and Android packages publish to npm and THEOplayer Maven; iOS / tvOS SwiftPM uses binary targets from the published xcframeworks, and CocoaPods uses the public-read THEOplayer Specs repo:

Platform Registry Coordinates
Web npm (npmjs.com) npm install @dolby-optiview/ads-sdk hls.js — plus @dolby-optiview/ads-sdk-core and @dolby-optiview/ads-sdk-adapter-{hlsjs,shaka,theoplayer}. The adapter test kit and the MCP server are not on npmjs; install them from the artefact host tarballs below.
Android / Android TV THEOplayer Maven implementation("com.dolby.optiview:ads-sdk-runtime:1.0.0-dev.1574") — plus ads-sdk, ads-sdk-core, ads-sdk-adapter-theoplayer, ads-sdk-adapter-test-kit, ads-sdk-react-bridge
iOS / tvOS Swift Package Manager .binaryTarget(url:checksum:) per xcframework — see iOS / tvOS (Swift Package Manager) below — products OptiViewAdsRuntime / OptiViewAdsSDK / OptiViewAdsCore / OptiViewAdsReactBridge / OptiViewAdsAdapterTHEOplayer / OptiViewAdsAdapterTestKit (test targets only)
iOS / tvOS CocoaPods (public-read THEOplayer Specs repo) pods OptiViewAdsRuntime / OptiViewAdsSDK / OptiViewAdsCore / OptiViewAdsReactBridge / OptiViewAdsAdapterTHEOplayer / OptiViewAdsAdapterTestKit (test) — see the Podfile snippet below

Android (Gradle). Add the public THEOplayer Maven repository to settings.gradle.kts, keeping Google and Maven Central for third-party libraries:

dependencyResolutionManagement {
    repositories {
        maven { url = uri("https://maven.theoplayer.com/releases") }
        google()
        mavenCentral()
    }
}

In the app module's dependencies, add implementation("com.dolby.optiview:ads-sdk-runtime:<version>"), replacing <version> with the exact stable release. This also resolves ads-sdk and ads-sdk-core. The app needs JDK 17, compileSdk 34 or newer, and android.useAndroidX=true.

Develop/custom builds do not go to the THEOplayer repository; use the unchanged artifact-host Android flow below.

CocoaPods. The same binary xcframeworks are also published as pods to the publicly readable THEOplayer Specs repo (never CocoaPods trunk). Add it as a source next to the default CDN (which still serves the Google IMA dependency pods), then depend on the layer you want — OptiViewAdsRuntime pulls OptiViewAdsSDK, OptiViewAdsCore and the per-platform Google IMA pods automatically:
source 'https://github.com/THEOplayer/cocoapods-specs.git'
source 'https://cdn.cocoapods.org/'

target 'MyApp' do
  pod 'OptiViewAdsRuntime', '1.0.0-dev.1574'
end
Lens observability dependency. The SDK reports through the OptiView Lens SDK on every platform (always on, no configuration — see Diagnostics). Lens is not on the public registries yet, so it is bundled inside the published Ads SDK artifacts — the npm tarballs, the Maven artifacts, and the binary Swift packages are self-contained, and installing them needs no private credentials or extra registries. Because the SDK carries its own Lens copy, do not add a direct Lens dependency next to the Ads SDK (that would give the app two copies). Building the SDK from source still needs private read access; once Lens publishes publicly, it becomes a normal public dependency and nothing in your integration changes.

The published packages are byte-identical to the artefacts below — the names (@dolby-optiview/ads-sdk*, com.dolby.optiview:ads-sdk*) are the same on the public registries and on the artefact host.

Artefact host

Direct distribution. Every build (including develop dev builds — versioned X.Y.Z-dev.N — and custom-branch builds, which never reach the public registries) is also hosted on the artefact host (https://ads-sdk.xagget.prudentgiraffe.com, one immutable <platform>/<branch>/<version>/ prefix per build) so you can install it with the standard package managers straight from an HTTP endpoint. Versions track the SDK release shown in the sidebar.

Web (npm)

Each web package — the SDK core, the entry SDK, the player adapters, and the AI helpers — is published as an npm pack tarball under /web/develop/1.0.0-dev.1574/. npm installs directly from a tarball URL, so no private registry is required:

# Entry SDK (defaults the ad player to HLS.js) + the HLS.js peer
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-sdk-1.0.0-dev.1574.tgz hls.js

The internal @dolby-optiview/ads-sdk* dependencies inside each tarball are rewritten to point at the matching tarball URLs on the same version prefix, so npm resolves the whole graph from the domain automatically — you only install the package you need.

Available packages:

Package Install
@dolby-optiview/ads-sdk npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-sdk-1.0.0-dev.1574.tgz
@dolby-optiview/ads-sdk-core npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-core-1.0.0-dev.1574.tgz
@dolby-optiview/ads-sdk-adapter-hlsjs npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-adapter-hlsjs-1.0.0-dev.1574.tgz
@dolby-optiview/ads-sdk-adapter-shaka npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-adapter-shaka-1.0.0-dev.1574.tgz
@dolby-optiview/ads-sdk-adapter-theoplayer npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-adapter-theoplayer-1.0.0-dev.1574.tgz
@dolby-optiview/ads-sdk-adapter-test-kit npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-adapter-test-kit-1.0.0-dev.1574.tgz
@dolby-optiview/ads-sdk-mcp (AI helpers) npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/1.0.0-dev.1574/dolby-ads-mcp-1.0.0-dev.1574.tgz

To pin a version, swap 1.0.0-dev.1574 for the version you want; the browsable index at /web/develop/1.0.0-dev.1574/ lists every tarball with its SHA-256, and manifest.json is the machine-readable index (versions, URLs, checksums, npm integrity hashes).

Bundler required. The web packages are ESM browser libraries (the same modules the demo consumes). Use them through a bundler (Vite, webpack, Rollup, esbuild, …) — they are not intended to be loaded directly by Node.

After installing, usage is identical to the registry flow — see Getting Started:

import Hls from 'hls.js';
import { OptiViewAds, HlsJsAdapter } from '@dolby-optiview/ads-sdk';

Android (Gradle)

The Android modules are published as a static Maven repository under /android/develop/1.0.0-dev.1574/ (group com.dolby.optiview). Add the repository, then the dependencies:

// settings.gradle.kts — dependencyResolutionManagement { repositories { … } }
maven { url = uri("https://ads-sdk.xagget.prudentgiraffe.com/android/develop/1.0.0-dev.1574") }
google()
mavenCentral()
// app/build.gradle.kts — dependencies { … }
implementation("com.dolby.optiview:ads-sdk-runtime:1.0.0-dev.1574") // Android runtime (Media3/ExoPlayer + IMA)
// ads-sdk (orchestrator) and ads-sdk-core (portable brain) resolve transitively.
# gradle.properties — REQUIRED
# ads-sdk-runtime pulls in Media3, which is AndroidX. Without this the build fails
# at `checkDebugAarMetadata` ("contains AndroidX dependencies, but the
# android.useAndroidX property is not enabled").
android.useAndroidX=true

The consuming app needs JDK 17 and compileSdk 34 or newer.

Artifact Coordinate
Android runtime (ExoPlayer adapter, overlay renderer, GAM/IMA DAI) com.dolby.optiview:ads-sdk-runtime:1.0.0-dev.1574
Player-agnostic orchestrator com.dolby.optiview:ads-sdk:1.0.0-dev.1574
Portable brain com.dolby.optiview:ads-sdk-core:1.0.0-dev.1574
PlayerAdapter conformance kit (test) com.dolby.optiview:ads-sdk-adapter-test-kit:1.0.0-dev.1574
THEOplayer PlayerAdapter (host app provides THEOplayer Android SDK) com.dolby.optiview:ads-sdk-adapter-theoplayer:1.0.0-dev.1574
React Native bridge core (pulled by the RN package) com.dolby.optiview:ads-sdk-react-bridge:1.0.0-dev.1574

Transitive third-party deps (Media3, IMA, kotlinx-coroutines) resolve from google() / mavenCentral() as usual. Sources jars are published alongside each artifact. The repo's POMs reference the sibling com.dolby.optiview:ads-sdk* modules, so adding the one maven { … } repository resolves the whole graph. Browse /android/develop/1.0.0-dev.1574/.

iOS / tvOS (Swift Package Manager)

The Apple modules are published as zipped binary .xcframeworks under /ios/develop/1.0.0-dev.1574/, consumed via SwiftPM .binaryTarget(url:checksum:). Each is its own dynamic module, so add the layer you want plus the xcframeworks it depends on:

xcframework Layer Also add
OptiViewAdsRuntime.xcframework.zip AVPlayer adapter + UIKit overlay OptiViewAdsSDK + OptiViewAdsCore + the Google IMA package (…-ima-ads-ios for iOS, …-ima-ads-tvos for tvOS)
OptiViewAdsSDK.xcframework.zip Player-agnostic orchestrator OptiViewAdsCore
OptiViewAdsCore.xcframework.zip Portable brain — (standalone)
OptiViewAdsAdapterTHEOplayer.xcframework.zip THEOplayer PlayerAdapter OptiViewAdsCore + theoplayer-sdk-apple ≥ 11.0.0, product THEOplayerSDK
OptiViewAdsReactBridge.xcframework.zip React Native bridge core OptiViewAdsSDK + OptiViewAdsCore
OptiViewAdsAdapterTestKit.xcframework.zip PlayerAdapter conformance suite (test) OptiViewAdsCore; test targets only
// Package.swift — full native runtime (AVPlayer + IMA)
dependencies: [
    // IMA ships per-platform — add the package(s) for the platforms you build for:
    .package(url: "https://github.com/googleads/swift-package-manager-google-interactive-media-ads-ios", from: "3.18.4"),
    .package(url: "https://github.com/googleads/swift-package-manager-google-interactive-media-ads-tvos", from: "4.2.0"),
],
targets: [
    .binaryTarget(name: "OptiViewAdsCore",
        url: "https://ads-sdk.xagget.prudentgiraffe.com/ios/develop/1.0.0-dev.1574/OptiViewAdsCore.xcframework.zip",
        checksum: "<see manifest.json>"),
    .binaryTarget(name: "OptiViewAdsSDK",
        url: "https://ads-sdk.xagget.prudentgiraffe.com/ios/develop/1.0.0-dev.1574/OptiViewAdsSDK.xcframework.zip",
        checksum: "<see manifest.json>"),
    .binaryTarget(name: "OptiViewAdsRuntime",
        url: "https://ads-sdk.xagget.prudentgiraffe.com/ios/develop/1.0.0-dev.1574/OptiViewAdsRuntime.xcframework.zip",
        checksum: "<see manifest.json>"),
    .target(name: "App", dependencies: [
        "OptiViewAdsRuntime", "OptiViewAdsSDK", "OptiViewAdsCore",
        .product(name: "GoogleInteractiveMediaAds", package: "swift-package-manager-google-interactive-media-ads-ios",
                 condition: .when(platforms: [.iOS])),
        .product(name: "GoogleInteractiveMediaAdsTvOS", package: "swift-package-manager-google-interactive-media-ads-tvos",
                 condition: .when(platforms: [.tvOS])),
    ]),
]
// Orchestrator only: OptiViewAdsSDK + OptiViewAdsCore.   Brain only: OptiViewAdsCore.
// THEOplayer adapter: OptiViewAdsAdapterTHEOplayer + OptiViewAdsCore, plus the theoplayer-sdk-apple package (product THEOplayerSDK).
// Adapter test kit (test targets only): OptiViewAdsAdapterTestKit + OptiViewAdsCore.

Each release's exact url + SHA-256 checksum for every product is in manifest.json (mirrored as a copy-paste snippet at /ios/develop/1.0.0-dev.1574/) — paste each checksum into its .binaryTarget. Slices: iOS + tvOS, device and simulator — the same zips serve iPhone/iPad and Apple TV apps; SwiftPM picks the right slice from your target's platform.

React Native (npm tarball)

The React Native package is a pure-JavaScript npm tarball published under /react-native/develop/1.0.0-dev.1574/:

npm install https://ads-sdk.xagget.prudentgiraffe.com/react-native/develop/1.0.0-dev.1574/dolby-optiview-ads-sdk-react-native-1.0.0-dev.1574.tgz

The tarball includes the TypeScript/JavaScript facade and the native bridge sources, but not the native OptiView Ads binaries or host-player SDKs. Add the native prerequisites for the platform and host player you use:

  • Android: register both the OptiView Ads Maven repository and THEOplayer's Maven repository. For a plain Android app, use the settings.gradle.kts dependencyResolutionManagement form shown in the Android section above. For a React Native app, add the repositories to the root android/build.gradle project's allprojects block instead:

    allprojects {
        repositories {
            maven {
                url "https://ads-sdk.xagget.prudentgiraffe.com/android/develop/1.0.0-dev.1574"
                content {
                    includeModuleByRegex "com\\.dolby\\.optiview", "ads-sdk(-.*)?"
                }
            }
            maven { url "https://maven.theoplayer.com/releases" }
            google()
            mavenCentral()
        }
    }

    For tagged stable Android releases, omit the versioned artifact-host entry: the THEOplayer /releases repository above serves the same Ads coordinates.

    React Native's Gradle plugin populates project-level repositories, so a settings-level repository can be ignored during React Native dependency resolution. If the Dolby repository is only registered in settings.gradle.kts, the build can fail with Could not find com.dolby.optiview:.... Keep the content filter: without it, Gradle may probe this static repository for unrelated dependencies and receive 403 Forbidden, which can prevent fallback to the repositories that own them. The app also needs Java 17, desugaring, and the native OptiView Ads artifacts. react-native-theoplayer is optional; react-native-video v6 uses the bridge's Media3 lookup without a compile-time dependency on that package.

  • iOS: add the OptiViewAdsReactNative pod through React Native autolinking. Its podspec can declare the native OptiView Ads pods it needs — OptiViewAdsRuntime

    • OptiViewAdsReactBridge (and, only when the app also ships react-native-theoplayer, OptiViewAdsAdapterTHEOplayer) — so pod install pulls them (and transitively OptiViewAdsSDK / OptiViewAdsCore / Google IMA) from the THEOplayer Specs repo automatically; no host-side SwiftPM/Xcode wiring of the OptiView Ads packages is required. Opt-in for now: those pod dependencies activate only with OPTIVIEW_ADS_NATIVE_PODS=1 in the pod install environment, until the pods are published to the Specs repo (first release after PUBLISH_COCOAPODS_ENABLED goes live — without a resolvable source the dependency would fail every pod install). With the gate on, the host Podfile only needs the Specs-repo source line (next to the default CDN — see the CocoaPods snippet above). react-native-video v6 needs no THEOplayer package; when using react-native-theoplayer, add its normal pod dependencies as usual. The podspec supports iOS/tvOS 15.1 or newer. Autolinking (from the installed npm package) is the only supported CocoaPods path — installing the pod straight from the git repository (pod 'OptiViewAdsReactNative', :git => …) is not supported.
  • Both platforms: use React Native 0.76 or newer with the new architecture enabled (TurboModules + Fabric), and install at least one supported host player: react-native-theoplayer or react-native-video v6.

Hosted services (SSAI stitcher + break-manifest server)

Two backend services run on the demo domain behind Traefik, routed by path prefix — useful for trying SGAI/SSAI without standing up your own:

Service Base URL Purpose
Break-manifest server (SGAI) https://ads-sdk.xnappet.live/manifest/v1/<orgId>/channels Store/serve break manifests; POST returns a channel url the SDK polls.
SSAI stitcher https://ads-sdk.xnappet.live/ssai/v1/<orgId>/<channelId>/master.m3u8 Server-side stitched HLS for configured channels.
# Store a break manifest, then poll the returned channel URL from the SDK
curl -X POST https://ads-sdk.xnappet.live/manifest/v1/<orgId>/channels \
  -H 'Content-Type: application/json' --data @manifest.json
# → { "channelId": "…", "url": "https://ads-sdk.xnappet.live/manifest/v1/<orgId>/channels/…" }

Both are Node/Express apps (@dolby-optiview/ads-sdk-stitcher, @dolby-optiview/ads-sdk-break-manifest-server) shipped as Docker containers; their compose + Traefik config live on the server under /srv/dolby-ads-config/ (canonical copy in the repo at deploy/services/).

VAST (CSAI)

The SDK can play VAST ad creatives client-side (CSAI — Client-Side Ad Insertion). A break asset of type vast carries a VAST ad-tag URL; the SDK fetches, parses, and renders it through the Google IMA SDK (ima3.js on web, the IMA Android/iOS SDKs on native) inside the SDK's overlay ad surface. This is the same IMA used for GAM DAI, but the client-side path (IMAAdsLoader / AdsRequest(adTagUrl:)) rather than the DAI pod-serving stream path.

VAST is SGAI-only — never SSAI. VAST assets are only honoured in client-side (SGAI) sessions. In a server-side (SSAI) session the server stitches the ad break and the client never reads the break manifest, so a vast asset there is rejected with a DA-VAST-SSAI-UNSUPPORTED diagnostic.

Supported formats (linear-only)

VAST describes a linear ad, so vast assets are only valid in the linear break formats. Non-linear placements are rejected (the creative would have nowhere to render as a linear ad):

Break format VAST asset?
single ✅ supported
double ✅ supported (ad + companion)
lshape_ad ✅ supported (ad + companion)
lshape_content ❌ rejected — DA-VAST-UNSUPPORTED-FORMAT
overlay ❌ rejected — DA-VAST-UNSUPPORTED-FORMAT

The format/SSAI guard (resolveVastAdmission) is part of the portable brain and is identical across the Web, Android, and iOS cores.

Asset shape

A vast asset sets type: "vast" and points uri at a VAST ad-tag URL. The response is XML, so mimeType is application/xml (or text/xml):

{
  "id": "ad-vast",
  "type": "vast",
  "mediaType": "video",
  "mimeType": "application/xml",
  "uri": "https://your-ad-server.example/vast?..."
}

See the full schema in the manifest spec.

Asset parameters on the tag URL

Since Break Manifest 1.4.0 a vast asset may carry assetParameters. The SDK merges them with SessionConfig.assetParameters, updateAssetParameters(), updateAssetParameterMacros(), and the manifest session layer (same per-key precedence as GAM), substitutes $MACRO$ values (see SessionConfig.assetParameterMacros on the Session page) and appends the result to the tag URL as query parameters — except inline data: tag URLs, which have no query component. Keys are sorted, encodeURIComponent-encoded, and inserted before any #fragment. The preloaded and the played tag URL are built the same way, so a warm tag stays reusable.

{
  "id": "ad-vast",
  "type": "vast",
  "mediaType": "video",
  "uri": "https://your-ad-server.example/vast?slot=preroll",
  "assetParameters": {
    "ua": "$OPTIVIEW_USER_AGENT$",
    "player_width": "$OPTIVIEW_PLAYER_WIDTH$",
    "player_height": "$OPTIVIEW_PLAYER_HEIGHT$",
    "pod_duration": "$OPTIVIEW_BREAK_DURATION_SECONDS$",
    "pub_macro": "$PUBLISHER_ID$"
  }
}

requests …/vast?slot=preroll&player_height=360&player_width=640&pod_duration=30&pub_macro=%24PUBLISHER_ID%24&ua=Mozilla%2F5.0… — the unknown non-OptiView $PUBLISHER_ID$ travels literally for the ad server to expand without a diagnostic, whereas an unknown $OPTIVIEW_* value is sent with its literal token and raises DA-ASSET-MACRO-UNKNOWN. A string macro value replaces its token. null omits the parameter; for VAST cust_params, an empty macro anywhere omits the whole parameter. undefined leaves an unresolved token literal and still sends the parameter. A null omission raises DA-ASSET-PARAMETER-OMITTED. A $ without a closing $ is left unchanged, and $APP$ and $APP_NAME$ never collide.

Preloading

VAST tags are resolved through IMA at break time. As with other ad assets, the SDK preloads against the next scheduled break per the manifest polling intervals. The web core uses a two-phase IMA lifecycle:

  1. VastAdManager.preloadVast(adTagUrl, options) runs when the break approaches (PRELOAD_AHEAD_SECONDS = 8). It fetches and parses the VAST tag and creates the IMA AdsManager, but does not start playback.
  2. VastAdManager.startPreloaded() runs at break start. Before it starts the held AdsManager, it calls resize() to re-read the ad <video> element's dimensions against the layout that is now applied (double / lshape_ad box, etc.). This corrects the slot size that preloadVast captured at arming time, before the box was laid out, preventing the ad from rendering too large or too small for its container.

If preload is skipped or fails, playVastAsset() falls back to the one-shot playVast() path, which still stamps the ad element to fill its box before IMA reads the dimensions.

Error handling

VAST failures are non-fatal — content always recovers. On any of the following the SDK raises an aderror event and emits a coded diagnostic, then resumes content:

Diagnostic When
DA-VAST-UNSUPPORTED-FORMAT vast asset in a non-linear break (overlay/lshape_content).
DA-VAST-SSAI-UNSUPPORTED vast asset in a server-side (SSAI) session.
DA-VAST-IMA-SDK-MISSING The IMA SDK (ima3.js / native IMA) is not available.
DA-VAST-IMA-ERROR IMA reported a load/parse/playback error for the tag.

Try it in the demo

The demo ships a dedicated VAST page (sidebar → VAST) that plays a sample VAST tag whose creative is the OptiView sample ad as an MP4 (no credentials) via Google IMA. The tag and MP4 are served from a public HTTPS host with CORS, not the demo origin: IMA fetches the tag cross-origin from its SDK iframe, and an HTTPS (secure-context) host also avoids Chrome's Private Network Access block that an HTTP-localhost page hits when an insecure IMA iframe tries to fetch a loopback resource. The creative is an MP4 (not HLS) because the IMA HTML5 SDK has no HLS engine — it plays the chosen MediaFile through the browser's native <video>, so an HLS-only creative fails off-Safari with VAST_LINEAR_ASSET_MISMATCH (VAST 403); MP4 plays in Chrome, Safari, and the rest:

  1. Open the VAST page (sidebar → VAST).
  2. Pick a content player (HLS.js / Shaka / THEOplayer / Native HLS) and an ad experience — the formats are limited to the VAST-admissible linear ones (single / double / lshape_ad).
  3. Choose Pre-roll (optionally Delayed 5 s) and/or Mid-roll (at least one). On the deployed demo the hosted manifest server (same origin, /manifest/v1) is used automatically; running locally (npm run dev) spins up the local manifest server for you.
  4. Click Run VAST demo. The page creates the break channel, starts an SGAI (CSAI) session, and IMA fetches/parses/plays the VAST creative at the selected break(s); the event log shows the adbreakbegin / adbegin / adbreakend flow.

You can also create a VAST channel manually from the Manifests page: choose the VAST — CSAI (IMA sample tag) preset, Create Manifest Endpoint, then play it on the Player page with the returned Org ID / Channel ID.

The VAST page (and index.html) loads the client-side IMA loader (ima3.js), so CSAI VAST plays out of the box on the dev server (npm run dev).

Pause ads

A pause ad is a full-screen image or video shown while the viewer pauses content playback, and dismissed when playback resumes. Unlike timebase-scheduled breaks, it is event-triggered: the SDK listens for the content player's pause event (via the PlayerAdapter) and renders the creative over the paused video; the player's playing event (resume) removes it. A video creative plays muted, once, and holds its last frame.

It is signalled in the break manifest with an event-triggered start: start: { "type": "event", "event": "pause" }. The variant uses a standard format (single); the creative is the first image or video asset of the first variant that qualifies for the device.

Breaking change. The legacy form (position: "pause", break-level delay as the pause delay, format: "pause" / "pause_image") is no longer parsed. Migrate: replace position: "pause" with the start object above, move delay into start.delay, and change the variant format to single.

Pause ads are SGAI-only. They are a client-side overlay and are not served in SSAI mode (the server controls insertion and the client never reads the break manifest).

Creative sources

The single asset's mediaType (image/video) selects the creative kind and its type (static/vast) selects the source — four combinations:

Source Asset How the creative is obtained
Static image type: "static", mediaType: "image" uri is a direct image URL fetched with a plain GET.
VAST image type: "vast", mediaType: "image" uri is a VAST tag; image = CompanionAds StaticResource.
Static video type: "static", mediaType: "video" uri is a direct MP4 URL played muted on the pause-video surface.
VAST video type: "vast", mediaType: "video" uri is a VAST tag; MP4 = Linear progressive MediaFile.

For a VAST source the SDK parses the XML itself (no IMA needed): for an image it reads CompanionAds/Companion/StaticResource, fires the companion creativeView impression on display, and opens CompanionClickThrough on click; for a video it reads the Linear progressive MediaFile, fires the Impression beacons on display, and opens VideoClicks/ClickThrough on click. See the manifest spec's “Expected VAST CompanionAds shape” and “Expected VAST Linear MediaFile shape”.

Resume affordance

Because the SDK overlay covers the player surface (and a native <video>'s own controls cannot be undercut), the SDK renders its own resume (play) button and a close button on top of the pause ad so the viewer can always resume — either dismisses the ad and resumes playback. The rest of the creative is the click-through target.

When the controls appear (web): both controls are hidden until the SDK allows resume (DA-PAUSE-AD-RESUME-ALLOWED):

Break Resume (play) and close (✕) buttons
controls.skipOffset set After skipOffset seconds on screen
Image, no skipOffset Immediately (or after duration seconds if duration > 0)
Video, no skipOffset When the creative ends (also on playback error or autoplay rejection)

A play button drawn over a playing video creative covers the ad and invites the viewer to skip it, so for video the controls are held back until the creative finishes. If the player resumes content while resume is not yet allowed (for example through the native controls), the SDK pauses content again, keeps the ad up, and records DA-PAUSE-AD-RESUME-BLOCKED.

Where playback resumes. Normally exactly where the viewer paused, with no seek — pressing resume continues the stream from the pinned position. The one exception is a live stream whose DVR window moved past that position while the ad was up: the paused moment no longer exists to resume into, so playback re-anchors to the live edge and the SDK records DA-PAUSE-AD-DVR-EXPIRED. VOD never hits this branch (its seekable range starts at 0). The decision itself is the portable resolvePauseResumePoint, locked across cores by the pause-resume-point conformance fixture.

Android/iOS: both controls still appear immediately and resume is always in place. Tracked in conformance/PENDING-PARITY.md; the native PlayerAdapters need a seekable-range accessor before the live-edge branch can be ported.

Asset shape (static video)

{
  "id": "pause-1",
  "start": { "type": "event", "event": "pause", "delay": 3 },
  "duration": 0,
  "controls": { "skipOffset": 5 },
  "variant": {
    "format": "single",
    "assets": [
      {
        "id": "pause-static-video",
        "type": "static",
        "mediaType": "video",
        "uri": "https://cdn.example.com/ads/pause.mp4",
        "interaction": { "clickThrough": "https://example.com/landing" }
      }
    ]
  }
}

VAST tag timeouts. A type: "vast" pause asset is resolved by fetching its tag, and that fetch is bounded at 5s on all three platforms (web AUX_FETCH_TIMEOUT_MS, Kotlin HTTP_TIMEOUT_MS, iOS httpTimeoutSec) — the same bound applies to the companion/backdrop image reads. A tag host that accepts the connection and then never answers therefore yields no pause ad after 5s instead of leaving the pause unanswered indefinitely.

Swap mediaType to "image" (and the uri to an image) for an image pause ad. duration: 0 keeps the creative on screen until the viewer resumes; a positive duration caps the on-screen time. start.delay (seconds, optional, >= 0) is how long the content must stay paused before the ad appears (default 0); a resume before the delay elapses cancels the pending ad. controls.skipOffset (seconds, optional) is how long the ad must stay on screen before the viewer may resume.

Rotation

A manifest may contain several pause breaks. The SDK shows them in manifest order, one per pause, and wraps around after the last one. The rotation index advances only when an ad is actually shown: a pause that is resumed before start.delay elapses does not consume the break, and a break whose creative cannot be resolved (DA-PAUSE-AD-NO-ASSET) is skipped in favour of the next one.

Try it

The Pause ads demo page shows the bundled content-player versions, accepts any reachable HTTP(S) HLS content stream, and displays the stream's current program date time in ISO-8601 UTC (Unavailable until the adapter receives a valid EXT-X-PROGRAM-DATE-TIME). In Generated manifest mode it creates a channel with a pause ad for the selected source — one of four: image/video × static/VAST — and starts an SGAI session. In Custom URL mode it skips local channel creation and passes the supplied full, signed manifest URL directly to the SDK. The page loads the Google IMA client-side SDK (ima3.js) so a custom manifest can also contain VAST/CSAI assets; the built-in pause VAST sources still use the SDK's direct parser described above. Remote content and manifest servers must permit browser CORS requests. Use the always-visible player control to play and pause on every supported content player — the pause image or video appears with a resume button; resuming dismisses it. Three toggles in Generated mode exercise the other manifest fields: Delayed sets start.delay: 2, Resume allowed after 5 s sets controls.skipOffset: 5 (the resume button appears only after 5 s), and Rotate two pause breaks adds a second pause break so consecutive pauses alternate between two creatives. The VAST sources use same-origin sample tags (public/pause-companion-vast.xml for the image CompanionAds, public/pause-linear-vast.xml for the video Linear MediaFile) so the parsers are exercised without a third-party ad server.

Pause-ad lifecycle is surfaced as diagnostics: DA-PAUSE-AD-SHOWN, DA-PAUSE-AD-RESUME-ALLOWED, DA-PAUSE-AD-RESUME-BLOCKED, DA-PAUSE-AD-DISMISSED, and DA-PAUSE-AD-NO-ASSET (skipped when no usable creative is found).

Customer demos

The demo app ships a set of customer demo pages — public, self-contained pages that show the OptiView Ads SDK integrated for a specific customer's stream and branding — plus a portal that lists them all. Every other page in the demo, including the portal itself, sits behind a site-wide login (see "The portal" below and packages/demo/src/site-gate.ts); only the customer demo pages themselves are public. This page is the guideline for building a new customer demo so every one shares the same look and feel.

Customer demos are deliberately separate from the internal SDK demo pages (Player, Pre-roll, VOD, …): they use a top status bar chrome instead of the internal left sidebar, and they carry the customer's branding.

Anatomy

Every customer demo has four parts:

  1. A registry entry in packages/demo/src/customer-demo/registry.ts — the single source of truth for the portal tile and the page's branding.
  2. A demo page — <customer>.html + packages/demo/src/<customer>.js, registered as a Vite input in packages/demo/vite.config.mjs.
  3. The shared shell (packages/demo/src/customer-demo/shell.ts) mounted at the top of the page.
  4. The shared "How it works" explainer (packages/demo/src/customer-demo/explainer.ts) mounted below the working demo (see next section).

The portal (portal.html) is generated from the registry; you do not edit it to add a customer.

The status bar contract

Every customer demo shows the same status bar, rendered by the shell:

  • Top-left: the customer's logo (or, if no logo is supplied, the customer name in bold).
  • Top-right: the Dolby OptiView logo (rendered black on the white bar).

There is no on-screen status pill — status and diagnostics are printed to the browser console instead. Mount the shell once at page start:

import { mountCustomerShell } from './customer-demo/shell';

mountCustomerShell({
  customerName: "Bally's",
  brand: {
    accent: '#d11e28', // secondary / accent colour
    radius: '12px', // rounded corners
    logoUrl: 'https://.../bally_logo_red.svg',
  },
});

mountCustomerShell injects the shared stylesheet once, applies the brand tokens to the page root as CSS custom properties, and inserts the status bar (customer logo left, Dolby OptiView logo right). It returns null (no status pill).

Branding tokens

A customer's look is defined entirely by the brand object on its registry entry, applied as CSS custom properties so one stylesheet themes every customer:

Token CSS variable Meaning
accent --brand-accent Secondary / accent colour — buttons, active states, the bar's bottom border. May be a CSS gradient.
radius --brand-radius Corner radius for cards, tiles, and the video frame.
surface --brand-surface Main background colour. Defaults to white when omitted.
text --brand-text Body text colour. Defaults to the dark ink #1a1a2e; set light for a dark surface.
statusbarBg --brand-statusbar-bg Status-bar background. Defaults to white; set dark for a dark-surface brand.
cardBg --brand-card-bg Card/panel background. Defaults to white; set to a dark elevated colour for a dark brand.
cardBorder --brand-card-border Card/panel border colour.
optiviewLogoFilter --brand-optiview-filter CSS filter for the (white) OptiView logo. Defaults to brightness(0) (black on a light bar); none keeps it white on a dark bar.
logoUrl — Customer logo shown top-left; falls back to the name as text.

Reserve the accent colour (or gradient) for accents — buttons, active states, the status-bar stripe — rather than large fills, so pages stay legible.

Light vs dark surfaces

The shell defaults to a light surface: any brand that sets only accent, radius (and optionally surface/logoUrl) renders exactly as before. For a dark brand (e.g. GloboPlay's black surface with an orange→red gradient accent), set the dark-theming tokens together so text and the OptiView logo stay legible:

mountCustomerShell({
  customerName: 'GloboPlay',
  brand: {
    accent: 'linear-gradient(90deg, #ff6a00, #ee0979)', // orange → red
    radius: '14px',
    surface: '#000000',
    text: '#f2f2f2',
    statusbarBg: '#0a0a0a',
    cardBg: '#141414',
    cardBorder: '#2a2a2a',
    optiviewLogoFilter: 'none', // keep the white OptiView logo white on black
    logoUrl: 'https://.../Globoplay-logo.png',
  },
});

The accent stripe under the status bar is painted on the bar's border-box layer, so a gradient accent shows there too (not only on buttons).

Choosing the content player

A customer demo can let the viewer pick the content player (for example HLS.js or Shaka) using the shared player picker (packages/demo/src/customer-demo/player-picker.ts). It renders one logo button per player from CUSTOMER_PLAYER_CHOICES (logos are self-hosted under public/players/, never hotlinked) and maps the choice to a player-factory library id:

import { mountPlayerPicker, playerChoiceToLib } from './customer-demo/player-picker';
import { createContentPlayer } from './player-factory';

const picker = mountPlayerPicker(document.getElementById('playerPicker'), {
  onChange: () => {
    /* rebuild the player on the next Start */
  },
});
// later, when (re)creating the SDK:
const lib = playerChoiceToLib(picker.getSelected()); // 'hlsjs' | 'shaka'
createContentPlayer({ lib, video });

The picker only offers players; the existing resolveContentPlayerLib (player-select.ts) still applies the no-MSE → native fallback for the default hlsjs choice.

Client-side ad-break manifest (no backend)

A customer demo can generate its ad-break manifest in the browser, with no ad-manifest backend, using the SDK's interceptManifestRequest hook. When the hook returns a body object, the SDK skips the network entirely and uses that body as the manifest (parsed and validated normally, on the initial fetch and every poll):

const sdk = new OptiViewAds({
  // …
  interceptManifestRequest: async () => ({ body: await buildManifestFromStream() }),
});

This is how a demo can turn a stream's own ad markers (for example HLS #EXT-X-CUE-OUT cues) into break manifests without any server. See the Bally's demo for a worked example.

Injecting pre-roll / pause on a live backend

A customer demo can instead get its mid-rolls from a real ad-manifest endpoint (configure manifestBaseUrl + startSession({ channelId })) and still demo a pre-roll and a pause ad by injecting them into the manifest the SDK has already fetched, via interceptManifestResponse. The reusable helpers in packages/demo/src/customer-demo/inject-ads.ts do this — the caller supplies the creatives, so each customer uses its own artwork/tags:

import { injectPreRoll, injectPause } from './customer-demo/inject-ads';

const sdk = new OptiViewAds({
  // …
  manifestBaseUrl: 'https://…/manifest/v1',
  interceptManifestResponse: (manifest) => {
    let m = manifest; // server mid-rolls
    if (preRollOn) m = injectPreRoll(m, { format: 'single', adUri: AD_TAG, delaySeconds: 0 });
    if (pauseOn) m = injectPause(m, { type: 'image', uri: PAUSE_IMAGE, delaySeconds: 0 });
    return m;
  },
});

injectPreRoll prepends a position: 'pre' break and injectPause appends an event-triggered break (start: { type: 'event', event: 'pause' }, single variant). Both are idempotent — the hook runs on the initial fetch and every poll, and each helper is keyed by its injected break id, so re-injecting never duplicates the break — they never mutate the input, and the server's mid-roll breaks are preserved. This is how the GloboPlay demo layers a pre-roll and a pause ad on top of its server-punched mid-rolls.

Both helpers accept an optional delaySeconds (default 0). For the pre-roll it maps to the break's delay: the seconds of content playback before the pre-roll fires. For the pause ad it maps to start.delay: the seconds the content must stay paused before the pause ad appears. injectPause also accepts skipOffsetSeconds, which maps to controls.skipOffset (the viewer may resume only after that many seconds). The Bally's and GloboPlay pages expose both delays as number inputs next to the pre-roll and pause controls.

The "How it works" explainer (PLAYG-294)

Every customer demo page mounts a shared, brand-themed explainer band below the working demo — one scrollable page tells the whole story: end-to-end architecture, how the SDK wraps a third-party player plus a copy-pasteable minimal web setup snippet, the AI onboarding tooling, and a capabilities matrix. Copy leads with the Dolby OptiView Ads brand name and the approved messaging ("turns your video stream into a monetized experience while keeping your origin, CDN, and streaming workflow unchanged … Everything else is managed by Dolby."), every section follows a Title → explaining paragraph → image order, and the copy is plain-language only — no "Under the hood"/"For developers" layer, no dash punctuation in the rendered prose, and no cross page links (the copy is self-contained). All four sections sit on the same Dolby-style dark purple gradient panel (brand independent), the whole band sits in one rounded, bordered container (.cd-explain, 24px radius, soft shadow) that follows the page's brand theme — a light card on a light brand (Bally's), a dark card on a dark brand (GloboPlay) — so it stands out as a single unit without clashing with the page, and each section carries just a headline, no eyebrow labels — the band's single title is the glowing labelled divider (cd-explain-divider, "How it works") that, together with a large top margin, separates the Dolby story from the differently styled demo above; the old four-link anchor nav was dropped as low-value. See the design proposal on PLAYG-297 for the full rationale and draft copy.

import { mountExplainer } from './customer-demo/explainer';

mountExplainer();

mountExplainer() is the only thing a new customer demo page needs to call — every section (diagram, code snippet, AI-tooling blurb, capabilities matrix) is fixed, shared content with no per-customer facts (packages/demo/src/customer-demo/explainer.ts). The pure renderer (renderExplainer) is unit-tested directly in packages/demo/src/__tests__/explainer.test.ts.

The diagram (packages/demo/src/customer-demo/explainer-diagrams.ts, renderArchitectureDiagram) is a single inline SVG — architecture and integration touchpoints combined, deliberately simplified rather than a 1:1 rebuild of the original epic mockups:

  • Three ownership lanes, kept to a plain one-word legend — red = Customer (Origin — no "(3rd-Party)" qualifier — and Player; no CDN node), black = Dolby (Dashboard, Ads API, Ads SDK), green = Ad Provider (never named as a specific vendor), consolidated into a single Ad Server node. The Dashboard sits alone on the top row; Origin, Ads API and Ad Server share the middle row; Player and Ads SDK share the bottom row — so every cross-lane arrow (break detection, Notify, Ad Insertion) plus the vertical Content arrow is one straight line. The Ads SDK's Ad call arrow still enters the green block from the bottom-centre so the two ad-provider touchpoints read as clearly different — no stitcher/SSAI/EABN terminology.
  • No flow list under the diagram — the labelled arrows speak for themselves; the three numbered touchpoints render as a vertical ordered list (cd-explain-steps), not one inline sentence.
  • No standalone "Operations" node. The Dashboard configures and schedules breaks directly on the Ads API (Configure & schedule); the break-detection link is drawn dashed, labelled Break detection, from the Ads API to the Origin (PLAYG-303 — the service reads the stream's own SCTE markers — optional/dashed; customers can create breaks via the Dashboard/Ads API instead).
  • Three numbered touchpoint badges sit directly on the nodes a customer touches — 1 Dashboard (configure), 2 Origin (connect your stream), 3 Player (add the SDK) — so there is no separate dimmed "integration" diagram to keep in sync with the architecture one.

The diagram renders inside a fixed light .cd-diagram-panel, independent of the page's own brand theme, so the semantic colours read correctly on both a light brand (Bally's) and a dark brand (GloboPlay).

The Ads SDK section explains the adapter approach (a thin translation layer; the snippet names only HLS.js as the example player) and the AI section explains the AI enablement in full: local knowledge installed by npx optiview-ads-init-ai, troubleshooting from a diagnostic report, and a player adapter skill for building a custom adapter for an uncovered player. The code snippet uses a small hand-rolled JS/TS tokenizer (highlightJs in explainer.ts) for real keyword/string/number/ function-call syntax highlighting (VS Code Dark+-style palette) instead of only colouring comments.

The capabilities matrix groups (CAPABILITY_GROUPS) each carry their own accent/tint colour pair instead of one flat grey pill — plain text pills, no player logos. Seven groups: Features (incl. a Delayed pre-roll pill — the break delay capability the demo pages expose — and Pause ads split into "(video)" and "(image)" pills), Ad formats (incl. L-Shape Content, the backdrop-only lshape_content format), Break scheduling (SCTE-35, EXT-X-CUE, EXT-X-DATERANGE, API, Dashboard), Ad controls (Countdown, Number of ads, Skippable, Snapback), Players (the actual players — Media3 / ExoPlayer, AVPlayer), Streaming (HLS, MPEG-DASH, HESP, Live, DVR, VOD), and Insertion (DAI, DAR, VAST) — shown as tags without prose explanation. The Players and Streaming groups each end with a muted grey "Any" pill (cd-explain-tag--muted), signalling an open-ended list rather than a fixed set.

Worked example: Bally's (/ballys.html)

The Bally's demo (packages/demo/src/ballys.js) is a live DVR demo built on THEOplayer:

  • Player. THEOplayer, because the stream is HLS with TS segments and THEOplayer transmuxes TS in the browser. TS transmux loads worker/wasm files from THEOplayer's libraryLocation, which must match the exact THEOplayer build actually running (see THEO_LIBRARY_LOCATION in packages/demo/src/player-factory.js) — if it points at a different version, transmux fails silently. Keep the configured library location and the installed theoplayer package version in lockstep.
  • Client-side manifest. Scte35Client (packages/demo/src/customer-demo/scte35-client.ts) fetches the stream's playlist (following one master → variant hop), parses its #EXT-X-CUE-OUT:DURATION= markers with the shared @dolby-optiview/ads-sdk-scte35-bridge parser, and returns a wallclock break manifest. Bally's carries no SCTE-35 binary payload and no EXT-X-DATERANGE, so the break start comes from the nearest #EXT-X-PROGRAM-DATE-TIME and the duration from the DURATION value. The manifest is handed to the SDK via interceptManifestRequest returning { body }, so there is no ad-manifest backend.
  • Ad format + generated Publica ad request. A mid-roll ad-format selector (single / double / L-Shape — VAST is linear-only) fills every detected break with a VAST ad. The tag is generated per break in the browser by buildPublicaVastTag() in packages/demo/src/customer-demo/publica-tag.ts, against Bally's Publica endpoint https://pbs.getpublica.com/v1/s2s-hb. Because that endpoint is server-to-server, the request must carry the viewer's context explicitly: their public IP (resolved once per session by fetchPublicIp(); omitted if the lookup fails), ua from navigator.userAgent, a stable session_id/did, a fresh cb cache-buster per request, and pod_duration — the break's length in milliseconds — so Publica sizes the pod to the break. GAM's pmnd/pmxd pod params are not used here (Publica ignores them); vast-pod.ts still serves the GloboPlay and Bell Media pages.
  • Pre-roll and pause ads. The whole manifest is assembled client-side by the pure packages/demo/src/customer-demo/ballys-manifest.ts (unit-tested) and returned from interceptManifestRequest; it takes a buildVastTag(durationSec) factory rather than a fixed tag, which is what lets each break mint its own ad request. The page exposes: an optional pre-roll with its own format selector — single / double / L-Shape (VAST) or an image overlay of the Bally's logo (non-linear, no VAST); and an optional static pause ad (image — the Bally Sports still — or an MP4 video), shown when the viewer pauses. The pre-roll break is 30s (BALLYS_PREROLL_DURATION_SECONDS) because Publica serves 30s creatives and a break's duration is a hard window — a 15s pre-roll had the SDK cut the ad at its midpoint. Pre-roll uses position: 'pre', pause uses start: { type: 'event', event: 'pause' } with a single variant. Each of the pre-roll and pause controls has a delay (seconds) input — the pre-roll delay (delay) is playback time before the pre-roll fires; the pause delay (start.delay) is time paused before the pause ad shows (both default to 0).
  • Detected-breaks list + seek-back. The mid-roll breaks from the latest client-built manifest are shown as a clickable list; clicking one seeks the player to 5s before that break's wallclock start (via the adapter's programDateTime, using the shared clampSeekTarget helper) so you can watch the break fire — the same DVR seek-back demo as the built-in DVR page.
  • Player UI + muted autoplay. Start mutes the THEOplayer and autoplays the stream (muted autoplay is never blocked by the browser). A basic control bar — play/pause, mute/unmute, a DVR seek slider over the seekable window, and a time readout — sits under the player. Muted autoplay also matters for correctness: until playback progresses THEOplayer does not expose EXT-X-PROGRAM-DATE-TIME, so the SDK logs the non-fatal DA-PDT-MISSING (wallclock breaks fall back to the system clock); once the stream is playing, PDT is available and the warning stops.
  • THEOplayer library must be same-origin for TS streams. The Bally stream is HLS/TS; THEOplayer transmuxes it via a worker + helper iframe.html loaded from libraryLocation. A cross-origin libraryLocation (e.g. a public CDN) makes that worker/iframe messaging stall silently — segments download but nothing is appended to the MediaSource, so you get a permanent black screen with no error. The demo therefore self-hosts the library on our own origin (THEO_LIBRARY_LOCATION = '/theoplayer/'; vite.config.mjs stages the installed build into public/theoplayer/, gitignored). fMP4 streams don't hit this (no transmux), which is why the built-in DVR demo never exposed it.
  • One ad per session_id/did — so the demo mints a new id per break. Measured against the live endpoint: Publica serves a single ad per device/session. With one id per viewer (as in Bally's reference script) the pre-roll filled and every later break of that session returned an empty <VAST version="3.0"></VAST> — which looked exactly like "DVR mid-rolls never get ads". Proven by isolation: the same mid-roll request (pod_duration=90000) fills when it is the session's first request, and server-side probes fill for every pod_duration (30000/90000/150000) and for stale IPs, so neither the pod length nor the ip was the cause. nextSessionId() therefore issues a fresh session_id/did per break (sharing one monotonic counter with cb, so no two minted ids can collide), which makes every break fill. Production should keep a stable device id — that cap is what makes an advertiser's frequency capping work; this is a deliberate demo-only deviation, and the page states it in the disclaimer band (below).
  • Page-level caveats live in one collapsed disclaimer band, not in the config panel. src/customer-demo/disclaimer.ts (renderDisclaimer / mountDisclaimer, styled by DISCLAIMER_CSS) renders a <details> band under the player: one always-visible line plus the notes on demand (US-only, the generated ad tag, the one-ad-per-session cap, and "an unfilled break is a no-fill"). These are properties of the stream and the ad server rather than help for any one control, and as .hint paragraphs in the Configuration panel they crowded out the controls and stopped being read. Notes are passed in by the page, so other customer demos can reuse the component; title and summary are escaped, bodyHtml is trusted in-repo markup (<code>, <strong>).
  • An empty VAST is a no-fill, not an SDK error. Publica answers 200 with <VAST version="3.0"></VAST> when it has no ad for the request; Google IMA then reports "empty VAST response" / "unable to find ad", which surfaces as DA-VAST-IMA-ERROR + aderror. The break still ends cleanly and content resumes. The page recognises that message and surfaces it two ways so it is not misread as a playback bug: a NO AD FILL notice toast over the player (amber badge, below the break-countdown pill, auto-hiding after 8s) and a NO AD FILL — … warning in the event log/console quoting IMA's own message. Check the US VPN and the ad server's targeting first. The generated request URL is printed in the event log on every manifest refresh, so it can be replayed with curl. Fill is bid-dependent: identical requests can fill on one break and return nothing on the next.
  • Embedded Xagget agent (dormant). The page embeds a Xagget device agent (customer-demo/xagget-agent.js) that stays inert unless opened with the Xagget bootstrap querystring vars (?deviceId=&broker=); when driven it exposes start/stop/seek/setConfig/getState/getLog. Note the broker needs the office VPN while the stream needs a US VPN, so live on-device runs of this stream aren't currently possible; local Playwright (US VPN, no broker) is the practical check.
  • Prerequisites. The stream is US-only, so enable a US VPN before starting. The in-browser playlist fetch also needs the stream to allow cross-origin reads; if it is blocked by CORS, add a dev-only Vite proxy for local runs.

Worked example: GloboPlay (/globoplay.html)

The GloboPlay demo (packages/demo/src/globoplay.js) shows the same SDK against a low-latency HLS stream with a live ad-manifest backend and a viewer-selected player — a useful contrast to Bally's THEOplayer + no-backend design:

  • Selectable player. The page mounts the shared player picker (HLS.js or Shaka, with logos) and builds the content player for the choice via player-factory. Both play the low-latency HLS stream https://ll-hls.softvelum.com/sldp/bbloop/playlist.m3u8.

  • Mid-rolls from a real endpoint (server-side breaks). Unlike Bally's, the break manifest is not built in the browser. The SDK is configured with manifestBaseUrl pointing at the staging ad-manifest service and started with the channel id, so it fetches and polls the channel over the network. Breaks are punched server-side into that channel.

  • Client-injected pre-roll + pause. On top of those server mid-rolls, the page layers an optional pre-roll and pause ad using the reusable injectPreRoll / injectPause helpers (see "Injecting pre-roll / pause on a live backend" above) via interceptManifestResponse. Both have a delay (seconds) input, so you can demo a pre-roll that fires after N seconds of playback or a pause ad that appears only after the content has been paused for N seconds. The pause ad uses a GloboPlay still:

    GloboPlay pause ad

  • Dark branding. GloboPlay is a dark brand: a black surface with an orange→red gradient accent and the GloboPlay logo, using the shell's dark-surface theming tokens (see "Light vs dark surfaces" above).

  • Embedded Xagget agent (dormant). Like Bally's, the page embeds the dormant Xagget device agent so it can be driven on a lab browser when opened with the Xagget bootstrap querystring; it stays inert in normal use.

  • Prerequisites. The content stream and the staging ad-manifest endpoint must be reachable from the browser (CORS); if a fetch is blocked, add a dev-only Vite proxy for local runs.

Worked example: Bell Media (/bellmedia.html)

The Bell Media demo (packages/demo/src/bellmedia.js) follows the same server-side-mid-rolls + client-injected-pre-roll/pause pattern as GloboPlay, with Bell Media's own stream, ad-manifest channel, and artwork:

  • Selectable player. Same player picker as GloboPlay (HLS.js or Shaka), playing Bell Media's stream (https://discovery.theo.live/v2/distributions/demo/hls/main.m3u8).
  • Mid-rolls from a real endpoint (server-side breaks). The SDK is configured with manifestBaseUrl pointing at the same staging ad-manifest service GloboPlay uses, started with Bell Media's own channel id, so mid-roll breaks are punched server-side.
  • Client-injected pre-roll + pause, with format-specific companions. The page layers an optional pre-roll and pause ad on top via injectPreRoll / injectPause. The linear formats (Single/Double/L-Shape) fill from the same VAST pod tag as GloboPlay/Bally's (pmnd/pmxd sized to the 15s pre-roll); unlike GloboPlay's single backdrop image, Bell Media's Double and L-Shape formats each additionally carry their own companion artwork — bell-doublebox.png for Double, bell-lbar.png for L-Shape — matching the format they are shown on. Overlay is a non-linear Bell Media logo image (no VAST), and the pause ad uses a Bell Media still (or an MP4 for the video option). Companion images apply to the pre-roll only; server mid-rolls play as delivered.
  • Light branding. Bell Media is a light brand (white surface, Bell blue #0065a4 accent, sampled from the Bell Media logo), using the same light defaults as Bally's.
  • Embedded Xagget agent (dormant). Same dormant Xagget device agent pattern as Bally's/GloboPlay.

Worked example: NBA (/nba.html)

The NBA demo (packages/demo/src/nba.js) adds two things the other customer demos don't have — a source selector and a three-way player picker:

  • Source selector: LL-HLS (live) | VOD. The page's headline feature is low-latency HLS, called out in a highlight band above the controls. The LL-HLS source plays the demo LL-HLS stream against a monetized staging ad-manifest channel (mid-rolls punched server-side, fetched over the network — the GloboPlay/Bell Media pattern). The VOD source plays the NBA VOD asset and requires the viewer to paste the asset's own ad-break manifest URL — Start refuses without it.
  • Pasted manifest URL → SDK config. The pure helper packages/demo/src/customer-demo/manifest-url.ts (parseManifestUrl, unit-tested) splits a standard <base>/<orgId>/channels/<channelId> URL back into the SDK's manifestBaseUrl + orgId + startSession({ channelId }) parts, so polling works exactly as if the page had been configured with them. A URL with any other shape falls back to interceptManifestRequest: the page fetches the URL verbatim on the initial fetch and every poll and returns { body }.
  • Selectable player, now including THEOplayer. The shared player picker gained a third choice (theo, self-hosted logo public/players/theo.svg), so the viewer picks HLS.js, Shaka, or THEOplayer; the page passes theoPlayerEl to createContentPlayer the way the internal Player page does.
  • Generated NBA backdrop artwork on Double / L-Shape. The pre-roll's Double and L-Shape formats carry NBA-branded companions generated for this demo — public/nba/nba-doublebox.png and public/nba/nba-lbar.png (1920×1080; SVG masters under docs/nba/). Both are designed around the SDK's exact layout geometry (Double: boxes at 1.04–48.96% / 51.04–98.96% horizontally, 26.04–73.96% vertically; L-Shape: pip inset top/left 5%, right/bottom 30%), so the branding lands in the visible margins/L and never under the video boxes. The same files are the hand-off artwork for server-side mid-roll companions on the real channel.
  • Pre-roll + pause ads. Same client-injected pattern as Bell Media (injectPreRoll / injectPause via interceptManifestResponse): linear formats fill from the shared GAM VAST pod tag sized to the 15s pre-roll, Overlay is a non-linear NBA logo image, and the pause ad is a generated NBA still (public/nba/nba-pause.png) or an MP4.
  • Dark branding. NBA is a dark brand: deep navy surface (#05070f/#0a1024), a blue→red gradient accent (#1d428a → #c8102e), light text, and the NBA logo (self-hosted public/nba/nba-logo.png), using the shell's dark-surface theming tokens.
  • Embedded Xagget agent (dormant). Same dormant device-agent pattern as the other customer demos; setConfig additionally accepts { source: 'llhls' | 'vod', vodManifestUrl }.

Adding a new customer demo

  1. Add a CustomerDemo entry to CUSTOMER_DEMOS in packages/demo/src/customer-demo/registry.ts (id, name, description, href, brand). The portal tile appears automatically, with the name shown in uppercase.
  2. Create <customer>.html and packages/demo/src/<customer>.js; mount the shell and wire the SDK for the customer's stream.
  3. Mount the shared explainer (mountExplainer from ./customer-demo/explainer, see above) below the demo markup, with the customer's customerName and wiredBullets.
  4. Register the page as a Vite input in packages/demo/vite.config.mjs.
  5. Add unit tests for any pure logic (manifest building, parameter helpers) and update this page and add a changelog/ entry (npm run changelog:entry).

The portal

portal.html is the overview page listing the customer demos — one tile per registry entry (uppercase customer name + a short description). It used to have its own independent password check; that's gone now (PLAYG-295) — the portal is just another page behind the site-wide login described below, no gate logic lives in portal.js anymore.

Every page in the demo except the customer demo pages themselves — the root demo, docs, the portal, and the internal non-customer-specific pages (Pre-roll, VAST, VOD, Pause, DVR, Manifests, Preset Builder, Bug Report, …) — sits behind a site-wide login (login.html, credentials optiview / bumba), enforced by the siteGatePlugin Vite plugin (vite.config.mjs) via a head-time inline redirect on every page except the ones in its public-file allowlist (login.html and the customer demo pages). Credentials are checked client-side (packages/demo/src/site-gate.ts) and the unlock is remembered in localStorage (da-site-unlocked), so a visitor only signs in once per browser. ?e2e=1 bypasses the gate for the demo's own automated tooling. Because the demo is a static site this is a soft, client-side gate, not real authentication — for a deployed site, enforce access with HTTP basic authentication at the reverse proxy too.

Ad Break Status & Countdown

adbreakstatus is the single source of truth for rendering ad-break UI — pre-break warnings, the "ad break in progress" badge, the seconds-remaining countdown, and the "ad N of M" counter. The SDK owns the timebase and the playback-gated ticking flag; your application just renders the snapshot it receives.

What it is

The SDK emits an adbreakstatus event whenever the break state or countdown changes, and exposes the current snapshot synchronously via sdk.getAdBreakStatus(). Both carry the same AdBreakStatus object:

Field Type Description
phase 'idle' | 'upcoming' | 'active' | 'complete' Current lifecycle phase of the break.
break Break | null The break this status describes; null while idle.
format BreakFormat Declared format of the selected variant, when known (single, double, lshape_ad, …).
secondsUntilBreak number Seconds until the break starts. Populated for upcoming warnings.
breakRemainingSec number Estimated seconds left in the active break, in the break's media time. For a GAM DAI pod (Web) this is the pod's single media timeline, so it counts down continuously across the pod's ads. undefined until it can be derived from playback progress.
adIndex number 0-based index of the ad currently playing. For a multi-ad GAM pod (Web) this counts the pod's real creatives, not the pod itself.
totalAds number Total ads in the break, when known. Reflects the pod's real composition for a multi-ad GAM pod on Web.
adsRemaining number Ads remaining: includes the one playing while an ad plays; after an adend, the ads still to come (0 after the last ad).
ticking boolean true only while the ad is rendered and playback is progressing; false while buffering or before a held pre-roll's first frame.
warningSec number The configured warning threshold that was crossed (only on upcoming warnings).

Phases

  • idle — no break active or upcoming. Hide all break UI.
  • upcoming — a pre-break warning fired (see Configuration). secondsUntilBreak is the live countdown to the break.
  • active — a break is playing. Show the badge; render the countdown from breakRemainingSec and the counter from adIndex / totalAds.
  • complete — the break finished. Dismiss the break UI (mirrors adbreakend).

How to use

Subscribe for live updates, and/or read the current status on demand:

import type { AdBreakStatusEvent } from '@dolby-optiview/ads-sdk-core';

// Live updates — fires on every state/countdown change.
sdk.addEventListener('adbreakstatus', (e: AdBreakStatusEvent) => {
  const { status } = e;

  if (status.phase === 'upcoming') {
    showToast(`Ad break in ${status.secondsUntilBreak}s`);
    return;
  }

  if (status.phase === 'active') {
    const counter =
      status.totalAds != null ? ` · Ad ${(status.adIndex ?? 0) + 1} of ${status.totalAds}` : '';
    if (status.ticking && status.breakRemainingSec != null) {
      showToast(`Ad break${counter} · ${Math.ceil(status.breakRemainingSec)}s remaining`);
    } else {
      showToast(`Ad break${counter}`);
    }
    return;
  }

  // 'idle' | 'complete'
  hideToast();
});

// On-demand — e.g. when (re)building your controls.
const current = sdk.getAdBreakStatus();
Drive your countdown from breakRemainingSec — do not run your own timer. The SDK gates the countdown on real playback progress (ticking), so it stays correct through buffering, tune-in (join-in-progress), and held pre-rolls, and it advances monotonically without flicker.

How it can be configured

adbreakstatus requires no configuration for the active / complete / idle phases — those are always emitted. The one configurable piece is the pre-break warning (phase: 'upcoming'), controlled by the breakWarnings option passed at construction:

const sdk = new OptiViewAds({
  manifestUrl,
  playerAdapter,
  // Fire an `adbreakstatus` (phase: 'upcoming') 10s and 5s before each break.
  breakWarnings: { seconds: [10, 5] },
});
Option Type Default Notes
breakWarnings.seconds number[] [] Seconds before a break at which to fire an upcoming warning. Normalised to positive integers, deduplicated, sorted ascending.
  • Each threshold fires once per break.
  • Warnings are skipped for pre-rolls — there is no content playback before a pre-roll, so a "break in Ns" warning has nothing to count down against.
  • With the default ([]), no upcoming warnings fire; you still get the full active countdown.

Using it for pre-roll

Pre-rolls are the most common place to show a countdown, and they need no special handling — subscribe once and render on phase === 'active':

  • No upcoming warning — a pre-roll has no pre-break content, so the first status you see for it is phase: 'active'.

  • ticking gates the first frame — for a held/first-frame-gated pre-roll, the status is emitted with ticking: false until the ad's first frame renders. Render a static "Ad break" badge while ticking is false, and switch to the live breakRemainingSec countdown once it flips to true. This avoids showing a countdown against a frame that has not started.

  • Monotonic countdown — once ticking, breakRemainingSec counts down smoothly to 0 (including CSAI/VAST pre-rolls, which advance from IMA ad progress). It never rewinds.

  • Dismiss on complete / adbreakend — hide the badge when phase becomes complete (or on the adbreakend event).

  • A short, healthy pre-roll can complete extremely fast. startSession() starts the break scheduler's 250ms tick and returns; a delay: 0 pre-roll can fire on that very first tick, and if the creative loads quickly the whole lifecycle (adbreakbegin → adbegin → adend → adbreakend) can complete in well under a second — potentially before your app resumes from await sdk.startSession(...) and gets around to attaching listeners or polling sdk.getAdBreakStatus(). Register adbreakstatus/ad-event listeners before calling startSession, not after, so you never race a fast pre-roll:

    sdk.addEventListener('adbreakstatus', onStatus);
    sdk.addEventListener('adbreakbegin', onBreakBegin);
    // … then start the session
    await sdk.startSession({ manifestUrl });
sdk.addEventListener('adbreakstatus', ({ status }) => {
  if (status.phase !== 'active') {
    breakToast.classList.toggle('visible', false);
    return;
  }
  breakToast.classList.add('visible');
  toastText.textContent =
    status.ticking && status.breakRemainingSec != null
      ? `Ad · ${Math.ceil(status.breakRemainingSec)}s`
      : 'Ad break';
});

See the Pre-roll concept page for how to schedule a pre-roll break, and the Events page for the surrounding ad-break lifecycle events (adbreakbegin, adbegin, adend, adbreakend).

React Native

The OptiView Ads SDK supports React Native (@dolby-optiview/ads-sdk-react-native) by bridging the existing native Android, iOS, and tvOS SDKs — a typed JS facade plus a TurboModule delegate to the native OptiViewAds orchestrator, which owns the PlayerAdapter, overlay rendering, and IMA. On web (react-native-web), the facade delegates to the web SDK.

RN app (JS)
  └─ @dolby-optiview/ads-sdk-react-native        typed facade: config, startSession(), events,
       │                            muted/volume, diagnostics, interceptors
       └─ TurboModule (bridge)
            └─ native OptiViewAds orchestrator (ads-sdk / OptiViewAdsSDK)
                 ├─ PlayerAdapter           wraps the *native* player instance
                 ├─ OverlayAdRenderer       native view above the player view
                 └─ GamStreamManager / VastAdManager (native IMA)

Lens observability — including the CMCD ad events — comes with those layers: React Native inherits it from the native SDKs (and from the web SDK under react-native-web), so there is no RN-specific dependency, configuration, or bridge surface for it, and reporting runs on the native side so it continues while JS is paused (PiP, background) — see Diagnostics.

All timing-sensitive logic — manifest polling, cue matching, break scheduling, ad progression, insertion — runs natively, so ad insertion keeps working while the RN JS thread is paused or throttled (Picture-in-Picture, background). Events are buffered natively and delivered when JS resumes.

On Android, bridge operations that access the host player are dispatched on the React Native UI queue before reaching the native player adapter. This includes session teardown, mute and volume controls, ad-break status, diagnostics, and the other instance-scoped bridge calls.

For Android THEOplayer facade builds, set usePlayerFacade: true at player creation. The SDK registers a native integration with its own Ads API. As on Web, full-surface single and lshape_ad breaks own playback and the clock across creative gaps; active-break SDK progress is forwarded unchanged, including whole-pod GAM time. Content playback notifications are hidden during takeover while source changes and destruction remain visible. SDK pause/seek notifications drain without reaching facade listeners, audio and skip route to the SDK, and session end clears ad state. The scheduler keeps reading the raw content engine. This registration is optional and does not add a THEOplayer dependency to apps using only react-native-video.

Supported players and bindings

The native bridge resolves the mounted React view from its native view tag, then asks the platform binding to wrap the underlying player in the matching native adapter. The rn-binding scenarios exercise the two native host-player bindings; they do not apply to the in-process web connector.

On Android, both bindings retain the React-managed wrapper as the overlay host and pass the host player's nested content surface separately for two-box geometry. The native bridge uses a self-measuring backdrop host below content and an ad host above it. Companion artwork is positioned on the painted picture rect in that host's coordinate space, while the backdrop host remains opaque over letterbox bars.

Host player / entry Android iOS tvOS Web
react-native-theoplayer Supported. React tag → ReactTHEOplayerView → native THEOplayerAdapter. Covered by rn-android (rn-binding-theo). Supported. React tag → THEOplayerRCTView → native THEOplayerAdapter. Covered by rn-ios (rn-binding-theo). Supported. The same Apple view-tag binding runs through the native tvOS adapter; covered by rn-tvos (rn-binding-theo). Supported through the RN web facade. The web nativeHandle is a THEOplayer ChromelessPlayer, wrapped by the existing web THEOplayerAdapter; covered by rn-web (rn-ads). No native rn-binding scenario applies.
react-native-video v6 Supported. React tag → ReactExoplayerView subtree → Media3 ExoPlayerAdapter; the typed useReactVideoHandle() / getReactVideoViewTag() helper supplies the tag. Covered by rn-android (rn-binding-video). Supported. React tag → RCTVideo layer tree → AVPlayerAdapter; use the same typed view-tag helper. Covered by rn-ios (rn-binding-video). Supported. The tvOS RCTVideo layer is wrapped by the same AVPlayerAdapter; covered by rn-tvos (rn-binding-video). Not supported by the demo web host. Its web handle stub does not yield a player, and createVideoDemoConnector() rejects this path.
react-native-web entry Not applicable. This is the browser entry, not a native host-player binding. Not applicable. Not applicable. Supported. The same facade is backed by the in-process WebOptiViewAdsConnector; rn-web (rn-ads) covers it.

Getting Started

Requirements

  • React Native ≥ 0.76 with the new architecture enabled (TurboModules + Fabric). Apple TV apps use the matching react-native-tvos release through the npm alias "react-native": "npm:react-native-tvos@<matching-version>".
  • A supported host player: react-native-theoplayer or react-native-video (v6) — the latter binds onto the existing Media3/AVPlayer adapters. On iOS/tvOS either host may be installed independently. Android automatically includes THEOplayer-specific code and dependencies only when its Gradle project is autolinked; no SDK host-selection flag is required. Combining native players still requires compatible Media3 dependencies.
  • The minimum react-native-theoplayer version is 11.7.0 on Android and Web for the experimental PlayerFacade API. Android uses a normal project dependency and typed API calls, without version or source-file probes. Web assumes a supported wrapper is installed, without runtime version checks. iOS/tvOS do not use PlayerFacade and may keep older wrappers. The optional npm peer range remains unrestricted for those Apple hosts.
  • Android: the native SDK artifacts (com.dolby.optiview:ads-sdk*) at the matching package version, from THEOplayer Maven for releases or the artifact host for develop/custom builds; Java 17, desugaring enabled.
  • iOS/tvOS: the OptiViewAdsSDK Swift package / pods, Xcode 16+, and one platform target per Podfile. Keep separate native project directories when one app ships both Apple platforms.

When the React Native package version changes or a native dependency is added or removed, refresh the independent Apple lockfiles on a Mac: cd react-native/demo/ios && bundle install && bundle exec pod install and cd react-native/demo/tvos && bundle install && bundle exec pod install, then commit both lockfiles. The Linux podfile:check gate checks direct dependency bookkeeping, while macOS CI verifies that the demo's pods resolve and integrate from a clean checkout. React Native podspecs are evaluated at install time and depend on the environment, so the committed lock is not expected to be byte-reproducible across machines.

Installation from public npm

Install the wrapper from public npm:

npm install @dolby-optiview/ads-sdk-react-native

For reproducible native setup, pin an exact published wrapper version with --save-exact; all OptiView native artifacts must match it. Keep either supported host player installed; neither optional peer requires the other. This adds only the RN wrapper to npm, not separate native packages. If your .npmrc already overrides the @dolby-optiview scope to a different registry, point that scope at https://registry.npmjs.org/ for this consumer. No override is needed with the default public registry.

Android: add the following repositories to your app's Gradle repository configuration. The bridge selects native dependencies at the installed wrapper version; keep Google/Maven Central for third-party dependencies.

// android/build.gradle — repositories used by the React Native projects
allprojects {
    repositories {
        maven { url "https://maven.theoplayer.com/releases" }
        google()
        mavenCentral()
    }
}

React Native's Gradle plugin may ignore settings-level-only repositories; ensure the repositories are available to the RN projects. Keep Java 17 and enable core library desugaring in android/app/build.gradle:

android {
    compileOptions { coreLibraryDesugaringEnabled true }
}
dependencies {
    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}

For a react-native-video host, add to android/gradle.properties:

RNVideo_useExoplayerIMA=true

The Android binding is selected automatically from autolinked Gradle projects. THEOplayer present means its typed integration is compiled; absent means its sources and dependencies are excluded. No version, source-file or method probes are performed, and no SDK host-selection flag or JavaScript API change is required. The demo's OPTIVIEW_HOST_PLAYER flag still selects which player is autolinked so its single-host APKs avoid Media3 conflicts.

This selects the real IMA integration rather than stub classes that conflict with the OptiView runtime's IMA SDK. Existing demo/test apps already set it.

iOS/tvOS: add both sources to each platform's Podfile alongside the normal React Native autolinking setup, then run bundle exec pod install there:

source 'https://github.com/THEOplayer/cocoapods-specs.git'
source 'https://cdn.cocoapods.org/'

The Specs repo is publicly readable over HTTPS and is not CocoaPods trunk; CI's SSH push keys are not consumer credentials. The npm variant's bundled OptiViewAdsReactNative.podspec defaults to OptiViewAdsRuntime and OptiViewAdsReactBridge at exactly the wrapper version. Core/SDK are transitive; OptiViewAdsAdapterTHEOplayer is added at that version only for THEOplayer hosts. The wrapper pod is autolinked from node_modules, not fetched from Specs.

Optional THEOplayer compatibility step: for react-native-theoplayer 11.5.0/11.6.0 with RN 0.87.0 or RN-tvOS 0.87.0-0 on Apple, the wrapper imports an absent React header. After installing dependencies and before running pod install, explicitly remove only this unused line from node_modules/react-native-theoplayer/ios/Theoplayer-Bridging-Header.h:

#import <React/RCTRootContentView.h>

Keep the other imports and persist this one-line change through the host app's dependency-patch process. Other versions require a compatibility review; prefer an upstream fixed wrapper when available. The SDK does not apply this workaround automatically. Existing apps and published SDK binaries are unchanged.

Set OPTIVIEW_ADS_NATIVE_PODS=0 at pod install to opt out and supply native dependencies yourself. OPTIVIEW_ADS_LOCAL_SWIFTPM=1 always wins and skips native pods. These npm defaults apply only to the staged public package marked optiviewAdsNativeDistribution: "cocoapods"; existing source/hosted/local packages and SwiftPM demos are unchanged. Web-only consumers use the browser entry and do not need native repository setup.

Existing hosted/source installations

The hosted tarball and local-store paths remain supported. Unmarked source, hosted and local tarballs keep their host-supplied SwiftPM default and existing OPTIVIEW_ADS_NATIVE_PODS=1 CocoaPods opt-in with unchanged constraints. OPTIVIEW_ADS_LOCAL_SWIFTPM=1 still suppresses native pods in these paths.

Install the package from the tarball on the artefact host (the same endpoint documented in the React Native install section), and add the native artifact repositories:

npm install https://ads-sdk.xagget.prudentgiraffe.com/react-native/develop/1.0.0-dev.1574/dolby-optiview-ads-sdk-react-native-1.0.0-dev.1574.tgz
// android/build.gradle — native OptiView Ads artifacts + THEOplayer
allprojects {
    repositories {
        maven { url "https://ads-sdk.xagget.prudentgiraffe.com/android/develop/1.0.0-dev.1574" }
        maven { url "https://maven.theoplayer.com/releases" }
    }
}

For tagged stable Android releases, omit the versioned artifact-host repository: the https://maven.theoplayer.com/releases repository above serves the same com.dolby.optiview:ads-sdk* coordinates. Keep /releases for stable dependencies and retain google() / mavenCentral() for third-party libraries. Develop/custom builds keep the artifact-host setup.

Quick start (react-native-theoplayer)

import { THEOplayerView } from 'react-native-theoplayer';
import { createOptiViewAds } from '@dolby-optiview/ads-sdk-react-native';

const onPlayerReady = async (player) => {
  const ads = createOptiViewAds(player, {});
  player.source = { sources: [{ src: CONTENT_URL, type: 'application/x-mpegurl' }] };
  ads.addEventListener('adbreakbegin', (e) => console.log('break', e.break.id));
  await ads.startSession({ manifestUrl: MANIFEST_URL });
};

<THEOplayerView config={{ license: THEOPLAYER_LICENSE }} onPlayerReady={onPlayerReady} />;

Quick start (react-native-video)

With react-native-video the view tag comes from the useReactVideoHandle() helper (its event payloads carry the tag at runtime; findNodeHandle on the v6 ref throws):

import Video from 'react-native-video';
import { createOptiViewAds, useReactVideoHandle } from '@dolby-optiview/ads-sdk-react-native';

const { player, onVideoEvent } = useReactVideoHandle();

useEffect(() => {
  if (!player) return;
  const ads = createOptiViewAds(player, {});
  void ads.startSession({ manifestUrl: MANIFEST_URL });
  return () => void ads.destroy();
}, [player]);

<Video
  source={{ uri: CONTENT_URL }}
  disableFocus
  onLoad={onVideoEvent}
/>;

On Android, disableFocus is required for react-native-video hosts. The native SDK overlay owns permanent media focus while an audible ad is on screen; a silent ad — a muted session, or the ad side of a double break with doubleBoxAudio: 'content' — claims none, so a focus-aware content player keeps playing. If the host requests AUDIOFOCUS_GAIN again when content resumes, Android permanently removes focus from the overlay and the ad can remain paused until the break timer ends. The SDK reports DA-AD-FOCUS-GRANTED, DA-AD-FOCUS-DENIED, and DA-AD-FOCUS-ABANDONED for the Android-only focus lifecycle, carrying breakId and cause so a host or QA harness can observe the whole break. DA-AD-FOCUS-LOST remains the negative signal and now carries breakId; when progress remains stopped, the SDK also reports DA-AD-STALLED with reason: "permanent-focus-loss".

For non-hook code, getReactVideoViewTag(event) extracts the tag (or undefined) from any react-native-video event payload.

React Native on the web

The package's platform-split src/index.web.ts entry exports createOptiViewAds and WebOptiViewAdsConnector from src/web/WebOptiViewAdsConnector.ts. This entry delegates directly to the in-process web @dolby-optiview/ads-sdk and existing web adapters: there is no TurboModule, native view-tag lookup, or bridge serialization.

For react-native-theoplayer, use the same call on native and web, with the player received by onPlayerReady and OptiViewAdsReactNativeConfig:

const ads = createOptiViewAds(player, config);

On web, the SDK unwraps player.nativeHandle, creates THEOplayerAdapter, and uses the browser player's element as its overlay container. The connector releases its adapter listeners on destruction, but never destroys the host player. A missing browser player or element throws; wait for onPlayerReady. Native targets validate the numeric view tag before calling the bridge.

The existing createOptiViewAds(webConfig) overload still accepts an explicit web adapter and container for custom integration; those remain caller-owned. Select the package's browser condition or explicitly import from @dolby-optiview/ads-sdk-react-native/web. The demo's Vite setup aliases react-native to react-native-web and selects .web.* modules.

To expose Dolby ads through THEOplayer's normal ad event and analytics surface, use a facade-capable RN THEOplayer build with usePlayerFacade: true. The shared factory automatically registers AdsSDKIntegration, forwarding break/ad starts and ends, quartiles, matching ad errors and clicks (adclicked). Web ad and break models use THEOplayer's supported integration: 'csai' kind so unmodified Bitmovin collectors classify them as client-side ads. Dolby identity remains available as customData.provider: 'optiview-ads'; this does not impersonate Google IMA. Analytics must observe the facade. The RN wrapper must accept and forward adclicked for web click reporting.

On iOS and tvOS, the native bridge registers its integration directly on the resolved THEOplayer instance. It dispatches the SDK's break/ad lifecycle, quartiles, errors, playing, waiting and sampled progress through THEOplayer's native Ads and player APIs. Full-surface ad playback exposes the sampled ad-local clock while the backing content is paused. The internal pause and resume-alignment seek sequence is absorbed until the seek settles; from adbreakend on, currentTime is the backing content player's own (it shows the resume target as soon as the SDK seeks), as on Android. The SDK's own adapter follows the backing content player rather than this integration, so the resume anchor and scheduler position are never the ad-local clock. The current native mapping reports the Google IMA integration kind because the creatives use GAM/IMA and supported analytics collectors classify that kind as client-side. Hosts other than THEOplayer do not install this integration. The bridge gives the overlay a persistent child view controller and passes that controller to GAM/VAST IMA. React Native fullscreen and native floating-player moves reparent the controller before the overlay view, preserving UIKit containment.

AdsSDKIntegration owns pause suppression. When the facade supports interceptPlayerEvent, the integration observes content pause events and consumes those expected from its own pauseContent() operations, plus any content pause while a content-replacing break owns playback. It handles queued events, repeated pauses, and failed or no-op calls; the facade contains no pause policy. This includes preroll holds before adbreakbegin. Raw content listeners and ordinary content pauses remain unchanged. Older facade builds fall back to raw pauses. With player-event dispatch support, ad playing, waiting and progress events reach facade consumers while full-surface ads own playback. paused is false during these ads, including buffering and creative gaps. Public play/pause commands are consumed during these ads: the SDK does not yet support pausing or resuming the ad itself. Overlay, double-box and content-backdrop formats retain content playback state and controls.

With interceptPlayerEvents, the integration filters every raw content playback event during single and lshape_ad breaks, not just a fixed list. Source changes and destruction remain visible. Break ownership lasts through adbreakend, even when the SDK has already started its content-resume work. Actual SDK content time writes arm seek suppression before the engine can emit events; delayed seeking / seeked stay hidden until completion, including adapter-deferred resume writes. The public seeking getter stays false during takeover and pending background seeks. Facade and connector viewer seeks after handoff release that background ownership. Raw SDK subscriptions still receive all events for scheduling and resume handling. Older facade builds without catch-all interception retain limited per-event filtering.

The integration provides its own Ads API object, including event subscriptions. playing remains true for the active break, including gaps between individual ads. The same ad and break objects remain available during their terminal callbacks and are cleared afterward. Scheduled lists are empty; ads.skip() delegates to the SDK's skip policy. Direct scheduling, target-position break skipping and server-side integration registration are unsupported and throw explicit errors.

Facade mute and volume delegate to the SDK's effective audio owner. Existing SDK synchronization preserves user audio settings across content/ad handoffs and respects double-box audio focus. The integration deduplicates volumechange notifications from the SDK and backing player.

Throughout single and lshape_ad breaks, the facade owns the clock, starting at zero and retaining the last SDK sample across creative gaps. Active-break adtimeupdate samples update the clock and forward player timeupdate unchanged, including whole-pod GAM time during individual creatives and slate. Other formats retain content time. The SDK's scheduler always reads the raw content player, never this ad clock. Session end clears break state; destruction also removes event subscriptions and registration. No skip, impression or completion reason is inferred. Plain browser players still work without facade integration.

Because this runtime is in-process, scheduling and insertion run on the browser main thread. The native bridge's guarantees about scheduling while the RN JS thread is paused in PiP or background, and native event buffering during that pause, do not apply. react-native-video has no supported web host in this demo; use the web THEOplayer path or a normal web SDK integration.

API notes

For RN-web THEOplayer, install useTHEOplayerAdsBinding when the player is created, even while ads are disabled, and pass its ready/destroy handlers to the view. Preparation precedes the application's ready callback and its first source assignment. Later ads activation and SDK replacement borrow the same player-owned presentation hierarchy, preserving the fullscreen containment and content DOM. Final SDK cleanup releases ad-owned resources; presentation cleanup follows actual player disposal. A playing, unprepared player is rejected without late wrapping. The hook's ownership and real configuration dependencies are unchanged. Native fullscreen controller containment and PiP source-swap behavior are unaffected.

  • createOptiViewAds(playerHandle, config) returns a OptiViewAdsConnector with startSession / endSession, addEventListener / removeEventListener, seek(time) (routed through the SDK's seek policy — suppressed during breaks with controls.snapback; prefer it over seeking the host player directly, which bypasses the policy), getMuted / setMuted, getVolume / setVolume, getAdBreakStatus(), skipAd(), clickAd(), exportDiagnostics(), getPresentationState(), notePictureInPicture(active), manifest request/response interceptors, and destroy().

  • skipAd() is asynchronous and resolves to true only when the active break's controls.skipOffset policy permits the skip. clickAd() resolves to the click-through URL (or null); it never navigates. The host application can use the returned URL with its own navigation policy.

  • The config is the web OptiViewAdsConfig minus its DOM/function members; events reuse the web OptiViewAdsEventMap verbatim, and live diagnostics reuse the web DiagnosticEvent shape:

    connector.onDiagnostic((diagnostic) => {
      console.log(diagnostic.code, diagnostic.message, diagnostic.context);
    });

    Remove a handler with offDiagnostic(handler). Native diagnostics are buffered while the JS thread is paused; exportDiagnostics() remains the snapshot API.

  • Getter-style APIs are async (Promise-returning) because they cross the bridge.

  • Asset parameters (SessionConfig.assetParameters, updateAssetParameters(), the manifest session layer vendorConfiguration.gam.sgai[0].assetParameters or ssai[0].assetParameters, and assetParameters on GAM and VAST assets; later layers win per key) and the built-in $OPTIVIEW_* macros work through the native SDKs unchanged. A changed manifest session layer is pushed to the live IMA session on the next manifest poll, without re-creating the IMA session. Customer macros use the same SessionConfig.assetParameterMacros map as web, no native code needed:

    import { OPTIVIEW_ASSET_PARAMETER_MACROS } from '@dolby-optiview/ads-sdk-react-native';
    
    const assetParameterMacros = {
      $SEGMENT$: 'sports',                                         // fixed value
      $CUSTOM_TEAM$: () => currentTeam(),                          // evaluated on every ad request
      $CUSTOM_USER_ID$: () => user?.id,                            // undefined -> literal "$CUSTOM_USER_IDquot; is sent
      [OPTIVIEW_ASSET_PARAMETER_MACROS.USER_AGENT]: 'MyApp/2.1',   // override a built-in
    };
    
    const assetParameters = {
      dimensions: '$OPTIVIEW_PLAYER_WIDTH$x$OPTIVIEW_PLAYER_HEIGHT#x27;, // macros inside a value -> "1920x1080"
    };
    
    await ads.startSession({ ...config, assetParameterMacros, assetParameters });

    Values and callbacks stay in JS; only the macro names cross the bridge. A string replaces the token, null marks it empty, and undefined leaves an unresolved token literal. While the native SDK builds an ad request, and before it pushes a changed manifest session layer to IMA, it asks JS for all values in one bounded (500 ms), fail-open round-trip. An unanswered or throwing callback counts as unresolved: the macro stays literal and the parameter is still sent; $OPTIVIEW_* and registered customer macros raise DA-ASSET-MACRO-UNKNOWN once per parameter key and macro, other names stay literal without a diagnostic. A null macro omits its parameter; for GAM cust_params, only the containing &-separated pair is dropped, and the key is omitted if no pair remains. For VAST, the whole cust_params parameter is omitted. This raises DA-ASSET-PARAMETER-OMITTED. updateAssetParameterMacros(macros) merges names during playback; null marks a name empty and undefined unregisters it so the built-in macro applies again, and new values apply to the next ad request. RN values are strings or callbacks like assetParameterMacros. A macro is the full $NAME$ token; a $ without a closing $ is left unchanged, and $APP$ and $APP_NAME$ never collide. Session macros are cleared by endSession().

  • Manifest interceptors run in JS with a bounded timeout (default 2000 ms) and fail open: if JS is paused or slow, the original request/response proceeds.

  • Ad insertion modes: the native bridge is SGAI-only today (client-side insertion; SSAI stitching is web-SDK only). SessionConfig.adInsertionType selects DAR (replacement) or DAI (insertion).

Demo app

A committed demo host lives at react-native/demo/ in the repository: configuration screen (host player — react-native-theoplayer or react-native-video — org, manifest base URL, channel, content URL, SGAI/SSAI mode, DAR/DAI insertion type, debug), player screen with event log and live ad-break status line, mute control, and diagnostics export to the OS share sheet. The react-native-video option uses the useReactVideoHandle() helper exactly as in the quick start above. See react-native/demo/README.md for build/run instructions (THEOplayer license required for the THEOplayer option only, never committed).

On Android the demo opts in to picture-in-picture (PLAYG-439): MainActivity declares android:supportsPictureInPicture and auto-enters PiP when the user (or a test host sending KEYCODE_HOME) leaves the app — setAutoEnterEnabled(true) on Android 12+, an onUserLeaveHint() fallback on API 26–30. This is what lets the Xagget rn-pip cells drive PiP host-side. iPhone simulators have no host-driveable PiP entry, so the iOS rn-pip cell is device-only.

On iOS, an app whose viewers use picture-in-picture with DAR needs three things (PLAYG-479, all shown in the demo): the Audio, AirPlay, and Picture in Picture background capability (UIBackgroundModes: [audio] in Info.plist — without it AVKit silently refuses to start PiP, and backgroundAudioConfiguration is ignored); player.pipConfiguration = { retainPipOnSourceChange: true } on react-native-theoplayer — during a break in PiP the SDK plays the ad through the content player (the PiP window presents that layer and nothing else), which is a source swap, and the library's default exits PiP on any new source, closing the window the moment an ad begins; and @dolby-optiview/ads-sdk-react-native with the PLAYG-481 fix, without which the SDK bridge itself cancelled host-entered PiP about a second after entry.

PiP with the react-native-video host needs one extra wire: connector.notePictureInPicture. The SDK learns about PiP from the host player's presentation-mode events — but react-native-video has no such event surface the bridge can observe, so without help the SDK keeps the declared break layout while the app is pinned. The integrator forwards the view's own status event (the demo's PlayerScreen shows this):

<Video onPictureInPictureStatusChanged={(e) => connector.notePictureInPicture(e.isActive)} … />

The call is state-only — the SDK forces multi-surface breaks to single while pinned but never opens or closes the window itself (PLAYG-481) — and it is ignored on hosts the bridge already observes (react-native-theoplayer), so wiring it unconditionally is harmless. On Android it resolves without acting: the Kotlin runtime watches activity-level PiP natively — reaching the Activity through the overlay's view ancestors, since React Native's ThemedReactContext unwraps to the application Context (a DA-PIP-AUTODETECT-UNAVAILABLE diagnostic means it found neither, and only then does the host have to report PiP itself).

Reading what the viewer actually sees: connector.getPresentationState(). While pinned, a forced-single ad plays through the content player (the PiP window presents that layer and nothing else) — so the player's own clock reports the ad's progress, and any UI or harness reading it directly would conclude content kept playing through a paid break (PLAYG-529). The SDK owns that truth, and the RN connector now mirrors the web OptiViewAds.getPresentationState():

const { pictureInPicture, showing, insertion } = await connector.getPresentationState();
// showing: 'ad' | 'content' — which media owns the surface right now
// insertion (iOS): 'overlay' | 'shared-element' — how the ad reaches the screen

Consult showing/insertion before interpreting the host player's position or presenting "now playing" UI during breaks.

Troubleshooting

  • OptiViewAds TurboModule not found — the new architecture is disabled or RN < 0.76. Enable newArchEnabled=true (Android gradle.properties) / RCT_NEW_ARCH_ENABLED=1 (iOS pod install).
  • RN 0.87 iOS fails on React/RCTRootContentView.h from react-native-theoplayer — versions 11.5/11.6 still import this removed old-architecture header even though they do not use the type. The committed demo applies scripts/patch-react-native-theoplayer-ios.cjs during postinstall; consumer apps on the same versions need an equivalent install-time patch until an upstream release removes the import.
  • Cannot find module '@dolby-optiview/ads-sdk-react-native' at runtime — when consuming the package via a file: link inside the monorepo, Metro must watch the package folder; see react-native/demo/metro.config.js for the reference configuration.
  • Android dependency resolution fails — register THEOplayer /releases for stable versions; develop/custom builds also need their versioned artifact-host repository. Use allprojects.repositories as above, keep google() / mavenCentral(), and enable desugaring.
  • Player never resolves — createOptiViewAds needs the view tag of the mounted player view: player.nativeHandle from onPlayerReady for react-native-theoplayer (not before mount), or the useReactVideoHandle() helper for react-native-video.
  • No events while backgrounded — expected: events are buffered natively while the JS thread is paused and flushed on resume; insertion itself continues natively.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Pending changes live as one file per change under changelog/ and are merged into [Unreleased] when a version is cut.

[Unreleased]

[1.0.0] - 2026-09-24

Added

  • The Android SDK's public API now carries KDoc documentation, visible in the IDE and rendered as an HTML API reference.
  • The web API reference now covers the hls.js, Shaka Player and THEOplayer adapter packages, and their TypeScript declarations carry full documentation.
  • The React Native SDK's exported API now carries full TypeScript documentation, rendered as an HTML API reference.
  • React Native THEOplayer integrations can use the same player setup on native and web.
  • React Native web THEOplayer integrations now expose ad events and ad playback time through the player facade.
  • React Native iOS and tvOS THEOplayer hosts now expose Dolby ad lifecycle, playback state and sampled progress through THEOplayer's native player and Ads APIs.
  • React Native apps can retain ads sessions across UI handoffs or let a React hook manage their lifetime.
  • Prepare missing browser compatibility support automatically during session startup without changing the SDK integration or replacing native APIs.
  • On iOS, content is pre-positioned or catches up behind a live break so the resume lands on the policy target.
  • React Native integrations can reuse player binding and optional ad debugging hooks without coupling playback to application UI.

Changed

  • The DA-RESUME-SEEK diagnostic now also reports what live resume alignment did during the break.
  • The React Native package now declares the OptiView terms-of-service license instead of MIT.
  • Android SDK internals that were public by accident are now marked @OptiViewInternalApi or made internal; an app that used them gets a compiler warning or error and should move to the public API.
  • The Apple SDK's internal scheduling, sequencing and parsing types are no longer part of the supported public API or the DocC reference.
  • Android React Native integration now uses typed THEOplayer APIs and requires react-native-theoplayer 11.7.0 or newer as a project dependency.
  • Quartile, click and ad error telemetry now carry the same creative and pod position dimensions as ad start and end.
  • Ending a session during an ad break now reports the partial ad watched and filled durations to Lens.

Fixed

  • An ad break whose ad fails to load no longer skips part of the content after the break.
  • Content resumes reliably after a picture-in-picture or shared-element ad break on Safari with Shaka Player, instead of staying paused.
  • GAM pod ad breaks now report the requested, filled and watched ad seconds in CMCD telemetry.
  • Prevent content decoder contention during ad transitions on single-decoder TVs and fully release native ad media before returning to content.
  • GAM pod ads that play through the content player (picture-in-picture on iOS and Safari, iPhone playback) are now reported to Google, so tracking beacons fire and, on iOS, each ad in the pod is reported separately.
  • After a break played through the content player on iOS, content that resumed normally is no longer reported as stalled or nudged with an extra play().
  • Ad stall and resume events (waiting and playing with source ad) are now emitted on iOS, and on the web when the ad plays through the content player; the break countdown pauses while an ad buffers.
  • Android: an immediate pre-roll now waits for content to be ready before starting and is cancelled when the content fails to load.
  • Fixed React Native web ad initialization with THEOplayer methods that cannot be reassigned.
  • Prevented internal content pauses from reaching React Native web player facade consumers during ad handoff.
  • Reported React Native web ad breaks as client-side ads for analytics while preserving Dolby provider metadata.
  • React Native web players now expose Dolby ad playback state and route mute and volume through the active audio owner.
  • React Native web playback no longer exposes background content events during content-replacing ads or internal seek notifications during the return to content.
  • Automatically exclude optional THEOplayer integration from Android apps using only react-native-video.
  • Include Android player bindings in the React Native npm package so clean app builds compile successfully.
  • React Native iOS fullscreen playback now preserves valid Google IMA view-controller containment when an ad break starts.
  • Content now resumes after an ad break on VIZIO SmartCast TVs by rebuilding the content player's media pipeline before resuming.
  • Preserve ad playback time during startup on Web, Android, iOS and React Native, and enable web THEOplayer metadata for ad tracking.
  • Improve ad playback compatibility on older Chromium-based devices.
  • The ad break countdown no longer reaches zero early during multi-ad pods, and the remaining-ads count is correct after each ad.
  • React Native apps can now select which side keeps audio during a double-box break via doubleBoxAudio.
  • On Android, a double-box break with content audio focus no longer pauses a content player that reacts to audio-focus loss.
  • Fix session startup on older Chromium devices when requests have no custom headers.
  • React Native iOS apps using THEOplayer now resume content at the correct position after a break.
  • A completed ad break no longer replays right after it ends when live content resumes on iOS or Android.
  • Keep iOS ad breaks working when captions change in THEOplayer.
  • Allow video ads up to ten seconds to start on Android and iOS, matching the web GAM pod timeout.

[1.0.0-beta.6] - 2026-09-11

Added

  • The React Native SDK can now be installed from the public npm registry.

[1.0.0-beta.5] - 2026-09-11

[1.0.0-beta.4] - 2026-09-11

Added

  • Asset parameter macros can resolve to null to omit the parameter (or, for GAM, the containing cust_params pair) from the ad request.
  • Android asset-parameter macros can return an empty value so the parameter that contains the macro is left out of the ad request.
  • On iOS, asset parameter macros can now return AssetParameterMacroValue.empty so the parameter that contains the macro is not sent to the ad server.

Changed

  • Android SDK releases are now available from the THEOplayer Maven repository.

Fixed

  • Ads now play the correct creative when picture-in-picture changes the selected variant after preloading.
  • Content restoration after shared-element ads now stops retrying and reports failures when the player does not respond.

[1.0.0-beta.3] - 2026-09-10

[1.0.0-beta.2] - 2026-09-10

Added

  • Asset-parameter macros can now be updated during playback with updateAssetParameterMacros(); the new values apply to the next ad request.
  • Android and iOS: asset-parameter macros can now be updated during playback with updateAssetParameterMacros(); the new values apply to the next ad request.
  • React Native: asset-parameter macros can now be updated during playback with updateAssetParameterMacros(); the new values apply to the next ad request.
  • Post-roll ad breaks (event-triggered on content end) now play on Android, iOS, tvOS and React Native.

Fixed

  • THEOplayer no longer ends an ad early when the previous ad's end event arrives after the next ad has started loading.
  • A pending pre-roll no longer begins when the content fails to load.
  • Shaka Player content load failures are now reported to the SDK as content errors.
  • Wallclock-scheduled live ad breaks now start reliably on Safari native HLS playback.
  • Ad pods that were preloaded now load every ad in the pod, not only the first one.
  • GAM pod ad tracking on Shaka Player and THEOplayer now receives ID3 markers when the playhead reaches them, so per-ad reporting is correct.
  • THEOplayer no longer reports the first ad's quartiles twice when a GAM pod starts.
  • React Native: a manifest interceptor that returns the mock manifest as an object is now applied on iOS instead of falling back to the network.
  • Android: adbegin and adend events for ads in a GAM pod now include the ad asset.
  • The npm packages now show the correct Dolby license and OptiView Ads SDK homepage.
  • Ads ended by the break-cut timer now report the CMCD end reason a (aborted) instead of c (completed).
  • React Native Android now reports a null channel id in the adchannelchange event when the switched channel has no id.
  • Asset parameters are no longer appended to inline data: VAST tag URLs, which made the ad response unparsable on Firefox.
  • Handle deferred picture-in-picture entry and prevent ad breaks replaying when content resumes on iOS.
  • THEOplayer on Safari now applies a seek made while the player is still seeking, so seeking back over a finished ad break re-arms it.
  • iOS: OptiViewAds and the renderer callback protocols are now main-actor isolated, fixing data races (crashes) when session, ticker, and renderer work overlapped on different threads.
  • Multi-ad Google Ad Manager pods now report one ad per creative even when the player starts the pod slowly or IMA reports the first ad early.
  • iOS: adbegin, adend and quartile events for the ads of a GAM pod now carry the per-ad asset, so React Native iOS receives these events instead of dropping them.

[1.0.0-beta.1] - 2026-09-08

Added

  • Pre-roll breaks now use the manifest specification event trigger (start: { type: "event", event: "start", delay }). The delay counts played content time only and starts when content playback begins.
  • Pause ads use the manifest specification event trigger: a pause ad is a break whose start is { "type": "event", "event": "pause", "delay": <seconds> } with a standard variant (for example single) holding an image or video asset. Several pause breaks rotate in manifest order, one per pause, and wrap to the first when exhausted; start.delay is the time the content must stay paused before the creative appears; controls.skipOffset keeps the resume and close controls hidden until the offset has elapsed (DA-PAUSE-AD-RESUME-ALLOWED / DA-PAUSE-AD-RESUME-BLOCKED). Web, Android, iOS and React Native.
  • Break manifests can use the mediatime timebase for VOD assets: break start is seconds from the start of the asset.
  • Post-roll breaks: a break with an end event trigger plays when content playback ends (web).
  • Android: when a polled break manifest switches to another ads channel, the SDK re-aligns its ad setup to the new channel and emits an adchannelchange event.
  • The SDK follows a manifest-driven ads-channel switch inside the running session and emits an adchannelchange event.
  • The break manifest server can serve a channel through a stable alias URL that can be re-pointed to another channel.
  • The SSAI stitcher accepts the mediatime timebase for VOD break manifests.

Changed

  • NFL live timeline reporting now uses the runner callout channel, allowing live NFL cells to run with the hosted MCP driver.
  • An ads-channel switch detected during an ad break now takes effect after the break has finished, and a switch before playback starts adopts the new manifest's ad start delay.
  • Demo presets and test manifests declare the mediatime timebase for VOD breaks instead of pts.
  • When the ads channel switches, the delivery mode (sgai or ssai) is resolved again from the new manifest and the player switches mode when it differs; the adchannelchange event now reports previousDeliveryMode and deliveryMode.
  • Session-level asset parameters from the break manifest now override the application's asset parameters and are applied when the manifest changes.
  • Asset-parameter macros now end with a closing dollar sign, for example $OPTIVIEW_USER_AGENT$, so a macro name that is a prefix of another name is never replaced by mistake.
  • An asset parameter whose macro has no value is now sent with the macro kept as its literal text instead of being dropped.

Removed

  • Legacy pause-ad manifest syntax. position: "pause", a break-level delay used as the pause delay, and the variant formats pause and pause_image are no longer recognised; BreakPosition is now only "pre". Migrate to start: { "type": "event", "event": "pause", "delay": <seconds> } with a single variant; the MCP validate_manifest tool reports the old fields and shows the new syntax.

Fixed

  • Seeking on Safari while the player is still completing a previous seek now lands at the new position instead of being ignored.
  • Pause ads now show when content is paused in Safari native HLS playback.
  • Ads no longer fail to start on Samsung Tizen TVs when the TV briefly pauses the ad element while playback is starting.
  • iOS/tvOS E2E agents now read brokerUser/brokerPass from xagget deep links.
  • The iOS SDK now reads manifest numbers with the value 0 or 1, such as a pre-roll start delay of 0, instead of ignoring them.
  • React Native: JavaScript asset-parameter macros are refreshed before a changed manifest session layer is pushed to the ad server, so the pushed values are no longer stale or literal.

[0.53.0] - 2026-09-05

Added

  • Generated API reference for the public web SDK surface (OptiViewAds, configuration and session types, events, diagnostics, and PlayerAdapter) under docs/api/.

[0.52.0] - 2026-09-04

Changed

  • BREAKING: useAnvatoID3 is replaced by ptsSource. Use ptsSource: 'anvatoCue' on web, PtsSource.ANVATO_CUE on Android, .anvatoCue on iOS/tvOS, or "ptsSource": "anvatoCue" in React Native for Anvato cue signaling; the default mediaTime keeps the previous behavior.
  • Diagnostic reports expose config.ptsSource instead of config.useAnvatoID3.

[0.51.0] - 2026-09-04

Not published: the release pipeline failed. The content below ships with the next release.

Added

  • Web and Android: opt-in continueContentDuringBreak keeps content playing hidden and muted behind eligible replacement breaks (default false).
  • Break Manifest 1.3.0 asset parameters: vast assets can carry assetParameters, $OPTIVIEW_* macros are expanded, and SessionConfig.assetParameterMacros registers custom macros (web, Android, iOS, React Native).
  • Break manifest signature verification on all platforms; an unsigned or invalid manifest is rejected with DA-MANIFEST-SIGNATURE-INVALID and schedules no breaks.
  • Manifest-driven delivery steering: the manifest's delivery rules choose the ad-insertion architecture per session on web, Android, and iOS/tvOS.
  • Ad-free session start via the manifest's adStartDelay, counted in played media time.
  • Anvato break signaling over DASH emsg on all DASH-capable platforms.
  • Break-variant selection with device targeting and format fallbacks on all platforms.
  • Lens observability is built into the SDK on every platform; CMCD ad events are reported through Lens.
  • GAM identity diagnostics DA-GAM-CONFIG-RESOLVED, DA-GAM-CONFIG-MISSING, and DA-GAM-POD-IDENTITY-MISMATCH.
  • Android and iOS/tvOS: AdScaling (fit or fill) controls how ad creatives fill the ad surface.
  • Android: audio-focus diagnostics DA-AD-FOCUS-GRANTED, DA-AD-FOCUS-DENIED, and DA-AD-FOCUS-ABANDONED.
  • Android: Lens reports carry the device class and a persistent user ID.
  • React Native: connector.seek(time), connector.getPresentationState(), and connector.notePictureInPicture(active).
  • React Native tvOS support.
  • CocoaPods distribution of the iOS/tvOS pods through a private Specs repository.
  • MCP: decode_cue, explain_concept, resolve_config, and validate_break_manifest tools; scaffold_quickstart covers SSAI and native platforms; explain_event_timeline reports per break.

Changed

  • BREAKING: npm packages renamed to @dolby-optiview/ads-sdk and @dolby-optiview/ads-sdk-<name>, Maven coordinates to com.dolby.optiview:ads-sdk*, and the SwiftPM package to optiview-ads-sdk. No compatibility aliases.
  • BREAKING: PlayerAdapter.liveSyncPosition renamed to maxLiveSeekPosition in all cores and adapters.
  • BREAKING: GAM is activated by the break manifest's vendorConfiguration; GamConfig.enabled is an explicit opt-out.
  • BREAKING: the break manifest decides where content resumes after a break on live and VOD; the SDK no longer defaults adInsertionType.
  • adPreload defaults to 'auto', and VIZIO SmartCast TVs are recognized as single-decoder devices.
  • CMCD adpos reports the 1-based position within the pod, platform and device-class tokens are abbreviated, and the manifest's organizationId is reported as adoid.
  • The Lens SDK is bundled into the published artifacts; installing the SDK needs no private registry.
  • A break variant with an unknown or missing format is skipped and reported.
  • A break with nothing to play no longer emits an empty adbreakbegin/adbreakend pair.
  • Android and iOS/tvOS: fullscreen ad surfaces and break layouts follow the visible content picture.
  • Published web packages are minified without source maps, Android artifacts are shrunk with R8, and iOS/tvOS frameworks are stripped.

Removed

  • BREAKING: gam.networkCode and SessionConfig.customAssetKey; the GAM stream identity comes from the break manifest.

Fixed

  • GAM pod ads on Shaka and THEOplayer pre-rolls no longer time out; cold GAM pods get a 10-second startup budget.
  • A viewer seek-back after a break re-arms DVR playback on all platforms.
  • CMCD ad telemetry sends brf, adast, and ader as bare tokens and reports correct timing, duration, and vendor error codes.
  • Every network read is bounded by a timeout on all platforms.
  • destroy() or endSession() during an in-flight startSession() no longer revives manifest polling.
  • A transient backward position blip no longer replays the just-completed break.
  • Anvato ID3 breaks preload as soon as the manifest arrives.
  • A healthy monetized tune-in remainder is no longer aborted by the wallclock window check.
  • Snapback enforcement no longer reacts to its own corrective seek.
  • Web: GAM-only breaks are skipped before adbreakbegin when the IMA stream session is unavailable.
  • Web: destroy() silences late diagnostics.
  • Web: the Shaka adapter reports programDateTime as null for live streams without EXT-X-PROGRAM-DATE-TIME.
  • Web: content resume after a break no longer races the ad decoder on single-decoder TVs.
  • Web: a stalled tune-in break can no longer run silent and replay in full.
  • Web: content returns when a break that adopted picture-in-picture mid-ad ends (Safari).
  • Web: a shared-element restore that never advances content is reported and repaired once.
  • Web: the ad stage follows the content media aspect ratio.
  • Web: the THEOplayer adapter no longer misses the ID3 track after a late attach.
  • Android: content stays paused when the host player auto-resumes during a break.
  • Android: content pauses when an ad starts before its first frame renders.
  • Android: picture-in-picture is detected under React Native.
  • Android: Maven artifacts are consumable from Kotlin 2.1.
  • Android: a break deleted from the manifest while playing returns to content instead of crashing.
  • Android: a cadence flip or session end no longer reports a false manifest poll failure.
  • Android (React Native): two-box break formats render as designed, companion artwork stays visible, and THEOplayer content-surface discovery works again.
  • iOS/tvOS: the ad surface tracks a mid-break resize.
  • iOS: entering picture-in-picture mid-ad no longer loses part of the creative, a window opened between sessions is recognized, and picture-in-picture closes at break entry on THEOplayer content.
  • iOS: an in-flight ad resumes after the app returns to the foreground.
  • iOS: concurrent diagnostics recording and manifest polling no longer crash.
  • React Native (iOS): endSession() no longer aborts the app while a GAM session is open; double, L-shape, and overlay layouts no longer collapse.
  • React Native (Android): GAM breaks open.
  • MCP: validate_break_manifest reports accurate results.
  • Demo: SSAI sessions start again.

[0.50.0] - 2026-08-17

Added

  • React Native: skipAd() and clickAd() viewer controls.

Changed

  • BREAKING: DolbyAds is renamed to OptiViewAds across every platform, with no compatibility aliases.
  • BREAKING: SessionConfig.manifestUrl is the only manifest input and is fetched verbatim; orgId, manifestBaseUrl, and channelId are removed.
  • BREAKING: assetParameters and updateAssetParameters() replace adTagParameters and updateAdTagParameters(); per-asset manifest parameters are applied and restored at break end.
  • BREAKING: SSAI sessions pass startSession({ stitcherUrl }); stitcherBaseUrl is removed.
  • The diagnostics report renames gam.hasAdTagParameters to gam.hasAssetParameters and reports manifestOrigin instead of orgId/manifestBaseUrl.
  • MCP scaffold_quickstart takes manifestUrl instead of orgId and channelId.

Fixed

  • React Native: ad click events reach JavaScript listeners.
  • Tune-in rules apply to joins that land inside the start tolerance.
  • A break that already ended when the playhead lands inside the start tolerance is skipped, and every break runs with a cut timer.
  • Instantly failing content-covering breaks announce content playback.
  • Web: double breaks degrade to a single fullscreen ad on phones and tablets, including modern iPadOS.
  • Web: content resumes after a shared-element break even when the restore stumbles; a second failure is reported as DA-SHARED-ELEMENT-RESTORE-FAILED.
  • Android: near-end resume seeks no longer strand the player in the ended state.
  • Android: post-break resume seeks are keyframe-aligned again.
  • iOS: a silent IMA request no longer stalls a GAM multi-break.
  • iOS: a break that ends without content resuming is reported as DA-CONTENT-RESUME-STALLED and repaired once.
  • iOS: GAM pods report their ads even when IMA reports before the break is bound.

[0.49.1] - 2026-08-13

Fixed

  • Web: a play button no longer covers a playing video pause ad.
  • Web: pause-ad resume re-anchors to live when the DVR window has moved on.
  • React Native: live SDK diagnostics are forwarded on Android and iOS through onDiagnostic/offDiagnostic.

[0.49.0] - 2026-08-13

Added

  • tvOS is a shipped platform: the Apple frameworks carry tvOS and tvOS simulator slices.
  • Releases publish to the public package managers as @optiview/ads-sdk.
  • The React Native iOS bridge packages are published as binary xcframeworks.
  • MCP scaffold_quickstart scaffolds React Native integrations for react-native-theoplayer and react-native-video.
  • iOS: break transitions reach Android parity with first-frame-gated cross-fade, dissolve at break end, deferred content pause, and the transition config.
  • Android: picture-in-picture is detected automatically; the host no longer forwards onPictureInPictureModeChanged.
  • Android: ad playback holds system audio focus for the duration of a break.
  • Genius Sports customer demo page.

Changed

  • React Native: the iOS pod and the Android bridge no longer require react-native-theoplayer when only react-native-video is used.
  • React Native documentation covers installation, the supported-player matrix, react-native-web integration, and the Android Maven setup.

Removed

  • BREAKING: the client-side polling override and fallback intervals; the manifest's polling object is the only cadence source.

Fixed

  • Removing a break from a polled manifest while it plays ends it immediately on all platforms.
  • Manifest polling matches the specification on all platforms.
  • React Native: connector.destroy() runs the host-player teardown on the main thread.
  • React Native (Android): bridge calls run on the player UI queue, overlay focus loss is diagnosed, and resume seeks stay exact.
  • React Native: the published package ships its podspec, the podspec no longer points at a nonexistent tag, and the tarball's dependencies resolve for clean installs.
  • iOS: content player errors keep their detail in DA-CONTENT-PLAYBACK-ERROR.
  • iOS: the host application's picture-in-picture is no longer cancelled.

[0.48.0] - 2026-08-07

Added

  • A hung startSession() reports the step it is waiting on (DA-SESSION-STARTING).

Fixed

  • NBA demo: GAM mid-roll pods play.

[0.47.0] - 2026-08-07

Added

  • NBA customer demo page.
  • React Native: react-native-video is supported as a second host player, with the useReactVideoHandle() helper.
  • React Native demo app and documentation.
  • The manifest fetch is bounded by a timeout on every platform.
  • A break abandoned by teardown is reported as DA-BREAK-ABANDONED.
  • A break that plays no ad is reported as DA-BREAK-NO-PLAYABLE-ASSET.
  • Android: a break the renderer cannot render is reported as DA-RENDERER-UNAVAILABLE, and an ad that stalls after starting as DA-AD-STALLED.
  • Android: fullscreen breaks cross-fade instead of cutting to black, and the transition is configurable.
  • Android: content plays under the break transition and is silenced from the moment the break begins.
  • Android: preloaded ads start with a frame already decoded.
  • Playback errors carry the cause, the requested and final URL, the HTTP status, and the response headers.
  • iOS and Android: DA-BREAK-TRANSITION reports sub-phase timing in debug mode.

Fixed

  • Android: content no longer stays paused after a break during which the viewer entered picture-in-picture.
  • Android and iOS: VAST ads are preloaded through IMA.
  • Android: GAM pods are preloaded.
  • Android and iOS/tvOS: quartile events fire for static assets.
  • iOS: the THEOplayer adapter no longer crashes on ID3 timed-metadata cues.

[0.46.0] - 2026-08-06

Fixed

  • SSAI stitcher: live manifests stay consistent across polls (append-only windows, correct discontinuity sequence, no creative looping).
  • SSAI stitcher: EXT-X-TARGETDURATION is raised when spliced ad segments exceed the origin's target duration.

[0.45.0] - 2026-08-04

Added

  • React Native: @dolby-ads/react-native with createDolbyAds(player, config), Android and iOS bridges for react-native-theoplayer, and a react-native-web runtime.
  • Web: DA-ANVATO-CUE-OBSERVED reports every Anvato break-signaling cue observed in the stream when useAnvatoID3 is enabled.

Changed

  • Bally's demo: the VAST tag is generated per ad break instead of typed in.

Fixed

  • Session teardown no longer emits DA-SESSION-ENDED twice or accepts late manifest polls.
  • Android: a multi-ad GAM pod reports one adbegin/adend pair per ad.

[0.44.0] - 2026-08-03

Added

  • pdtGraceSeconds configures how long a wallclock session waits for the stream's Program Date Time (web, Android, iOS).
  • adPreload: 'none' disables all pre-break activity.
  • Preload diagnostics DA-PRELOAD-MODE-RESOLVED and DA-PRELOAD-PATH.
  • iOS: a multi-ad GAM pod reports one adbegin/adend pair per ad.

Fixed

  • Android: playing is emitted once per playback start.
  • iOS: IMA receives timed metadata for GAM pods, so ad events and tracking beacons fire.
  • A wallclock session no longer schedules breaks against the system clock while waiting for the stream's Program Date Time.
  • A DAI mid-roll's post-break resume seek no longer re-fires the break on native HLS players.
  • Live mid-rolls on the native Safari adapter no longer re-fire a break that just ended.
  • The install documentation points at the current artifact host.

[0.43.1] - 2026-08-03

Fixed

  • Web: a GAM pod with several ads is reported as one adbegin/adend pair per ad, and quartile events are attributed to the right ad.
  • Published artifacts reference the host they are published to.

[0.43.0] - 2026-08-02

Added

  • iOS: the SDK drives system picture-in-picture for the content player, and getPresentationState() reaches parity with web and Android.
  • iOS: a viewer who opens picture-in-picture during a break gets the single layout.
  • Web: the SDK plays shared-element ads itself instead of handing them to the content engine, and PlayerAdapter.releaseMediaElement() lets the engine release its media element for the ad.

Fixed

  • iOS: the picture-in-picture format override reaches the public events, and picture-in-picture opens reliably.
  • Web: the SDK tracks the content element when the player swaps it.
  • Web: THEOplayer plays ads through the content element.
  • Web: a shared-element ad that never renders a frame ends the break with an error instead of silence.
  • Web: picture-in-picture state is reported consistently.

[0.42.0] - 2026-07-31

Added

  • Picture-in-picture forces the single layout on web and Android.
  • setPictureInPicture(), isPictureInPicture(), and getPresentationState() on web and Android.
  • Shared-element insertion works over MSE.
  • DA-PIP-TRANSFER-FAILED reports a picture-in-picture window that could not move to the ad element.

Fixed

  • Shared-element breaks report ad progress and quartiles.
  • A break no longer fires again immediately after ending.
  • The content gate no longer freezes an ad that took over the content element.
  • Android: an ad that never starts raises aderror instead of running the break out in silence.

[0.41.1] - 2026-07-30

Fixed

  • Shaka delivers Anvato break cues to useAnvatoID3.

[0.41.0] - 2026-07-30

Added

  • DA-BREAK-TRANSITION reports where a slow transition spends its time.

Fixed

  • iOS: Anvato cues are delivered when the integrator loads content directly.

[0.40.2] - 2026-07-30

Fixed

  • Safari delivers Anvato break cues to useAnvatoID3.

[0.40.1] - 2026-07-30

Fixed

  • Anvato cue-scheduled breaks no longer replay in a loop on live players.
  • Android: content resumes after a pausing break on a live stream with a pts timebase.

[0.40.0] - 2026-07-30

Added

  • DA-BREAK-TRANSITION measures the cost of every playback transition around a break.

Fixed

  • adPreload: 'auto' no longer deadlocks playback on modern Smart TVs.

[0.39.0] - 2026-07-30

Added

  • Anvato in-stream break signaling via the useAnvatoID3 option.

[0.38.0] - 2026-07-29

Added

  • Android: skipAd(), clickAd(), and the adclick event.

Fixed

  • iOS: pre-roll breaks fire.
  • Pause-position breaks are no longer scheduled as timeline breaks.
  • iOS: client-side IMA requests run on the main thread and no longer fail on a missing view controller.
  • iOS: chained breaks no longer re-fire during a seek.

[0.37.0] - 2026-07-27

Fixed

  • Per-URI deviceType asset targeting is applied.
  • Quartile events fire for static assets.
  • double, lshape_ad, and lshape_content breaks reveal content smoothly instead of cutting to black.

Changed

  • Documentation covers the creative CORS requirement, fast pre-roll completion, and DAR resumeOffset semantics.

[0.36.0] - 2026-07-26

Added

  • Bell Media customer demo page.

Changed

  • Customer demo explainer: delayed pre-roll callout and labelled break-detection arrow.

[0.35.0] - 2026-07-24

Added

  • Demo site: site-wide login gate.
  • Customer demo pages: shared "How it works" explainer.

Fixed

  • A pre-roll whose ad fails to load keeps a balanced adbreakbegin/adbreakend lifecycle.
  • Demo pages: the control bar responds and fades during an ad break.

Changed

  • GloboPlay demo: new backdrop image for the double-box and L-bar pre-rolls.

[0.34.0] - 2026-07-15

Added

  • doubleBoxAudio selects whether the ad or the content is audible during a double break.
  • Unified mute and volume API for content and ad playback.
  • Android and iOS/tvOS: adbreakstatus event and getAdBreakStatus() API, with a countdown driven by real ad playback.

Fixed

  • Demo pages: the control bar and break toast keep their size during double and L-shape breaks.
  • Bally's demo: ad breaks start at the correct segment.
  • Live GAM mid-rolls no longer re-fire, and the countdown badge is stable.

Changed

  • GloboPlay demo: updated content stream.

[0.33.1] - 2026-07-13

Added

  • Documentation: "Break Status & Countdown" page.
  • Bally's and GloboPlay demos: configurable delay for pre-roll and pause ads.
  • GloboPlay customer demo page.

Fixed

  • Preloaded VAST ads resize reliably and fit their box in double and L-shape breaks.
  • VAST pre-rolls: the countdown no longer freezes or rewinds, and content no longer plays behind the ad.
  • Demo: the Player page pre-roll uses the correct manifest field.

Changed

  • Bally's and GloboPlay demos: clearer format controls and a separate logo overlay.

[0.33.0] - 2026-07-12

Added

  • adbreakstatus event and getAdBreakStatus() API for break countdown UIs (web).
  • VAST ads are preloaded ahead of the break.

Changed

  • Preload lead time increased to 8 seconds.
  • Bally's demo: the default VAST tag is a GAM live pod endpoint.

Fixed

  • SCTE-35 cue timing computes the break start from Program Date Time plus segment durations.
  • Bally's demo: the full DVR window of breaks is retained; black video, pre-roll no-fill, and the pause image are fixed.

[0.32.0] - 2026-07-12

Added

  • Bally's DVR customer demo page and a password-protected customer demo portal.
  • @dolby-ads/scte35-bridge and a DVR demo page.

Fixed

  • Mid-roll DAI breaks no longer re-fire after they end.
  • VAST ad video fills its box.

[0.31.0] - 2026-07-12

Added

  • Break manifest server: stored manifests expire two hours after they were last fetched.

[0.30.0] - 2026-07-12

Changed

  • Demo: default live stream and OptiView Ads domain updated.

[0.29.0] - 2026-07-12

Added

  • Wallclock breaks match the stream's Program Date Time, enabling DVR seek-back re-fill.

Fixed

  • destroy() disposes the ad player created by adPlayerFactory.
  • THEOplayer adapter: progressive MP4 and WebM sources no longer hang.
  • diagnose() no longer throws on an unknown diagnostic code.

[0.28.0] - 2026-07-01

Fixed

  • A break cut short emits a balanced adend for the in-flight ad.
  • GAM pod serving forwards timed metadata to IMA and emits balanced adbegin/adend events.

[0.27.0] - 2026-06-27

Added

  • interceptManifestRequest lets integrators inspect and modify the manifest request before it is sent.

[0.26.0] - 2026-06-27

Added

  • interceptManifestResponse lets integrators inspect and modify the manifest on the client.
  • Every break and ad event carries a format field.
  • Demo: Preset Builder page and predefined stream configurations on the Player page.

Fixed

  • Ad audio no longer keeps playing after a session ends mid-break.
  • Demo: L-shape content backdrop link, preset switching, and stale status bar fixed.

[0.25.0] - 2026-06-24

Added

  • Video pause ads on all platforms; the break format is pause (the legacy pause_image is still accepted).
  • Demo: File a Bug page bundling a redacted diagnostics report.
  • Demo: the Player page remembers session fields.

[0.24.0] - 2026-06-23

Added

  • Android and iOS/tvOS: pause ads.
  • Demo: pre-roll on the main Player page.

Fixed

  • A pre-roll no longer replays after the manifest is re-polled.

[0.23.2] - 2026-06-23

Fixed

  • Demo: the player video no longer grows beyond the screen on reload.

[0.23.1] - 2026-06-21

Fixed

  • No content flash before an immediate pre-roll.

[0.23.0] - 2026-06-21

Added

  • Demo: VOD page skip-forward and skip-back buttons.

[0.22.1] - 2026-06-21

Fixed

  • Demo: the VAST page sample ad plays in Chrome.
  • Demo: the local break-manifest server starts reliably.

[0.22.0] - 2026-06-21

Added

  • Demo: the VOD page can load a published channel by ID.
  • Demo: current-time indicator on all player pages.

[0.21.1] - 2026-06-19

Changed

  • Demo: the VAST sample tag is self-hosted.

[0.21.0] - 2026-06-19

Added

  • Demo: VAST page.
  • Documentation: features overview, VAST guide, and a step-by-step getting-started guide.
  • Documentation: dedicated AI Assistance page.

Fixed

  • Demo: the AI Assistance sidebar item appears on every page.
  • Deployed demo: the VOD, Pre-roll, and Manifests pages use the hosted manifest server.

[0.20.0] - 2026-06-19

Added

  • Web, Android, and iOS SDK artifacts are published for installation from the demo domain.
  • Android and iOS/tvOS: VAST client-side ad playback.
  • Demo: VOD page and Pre-roll page with content-player and ad-experience selectors.

[0.19.0] - 2026-06-18

Added

  • SessionConfig.adInsertionType ('replacement' or 'insertion') controls how a break relates to the content timeline.
  • Web: VAST client-side ad playback.
  • Pre-roll breaks (position: 'pre') with an optional delay.

Fixed

  • The default ad player routes progressive MP4 creatives to the native <video> element.
  • A mis-configured shared-element insertion no longer stalls content on non-iOS platforms.

[0.18.0] - 2026-06-17

Added

  • @dolby-ads/break-manifest-server stores and serves static break manifests.
  • overlay break format with image overlay assets on web, Android, and iOS.

[0.17.0] - 2026-06-15

Added

  • Version API on every platform.

[0.16.0] - 2026-06-15

No customer-facing changes.

[0.15.0] - 2026-06-15

Added

  • Web: client-side SSAI mode (mode: 'ssai') playing a pre-stitched stream from the stitcher.
  • Stitcher: max_bitrate rendition filtering.
  • timedmetadata capability on the PlayerAdapter contract.
  • Diagnostic codes DA-SSAI-SESSION-FAILED and DA-SSAI-IMA-ERROR.
  • Demo: SSAI support.

Fixed

  • Stitcher follows redirects and resolves relative URIs against the final URL.
  • Stitcher absolutizes #EXT-X-MAP, #EXT-X-KEY, and other URI-bearing tags.

[0.14.0] - 2026-06-13

Added

  • Stitcher: double and lshape_ad breaks are stitched as a fullscreen single ad.

[0.13.0] - 2026-06-12

Added

  • @dolby-ads/stitcher: server-side ad stitcher for single breaks.
  • @dolby-ads/core/server entry with the server-safe manifest utilities.

[0.12.0] - 2026-06-12

Added

  • adBreakCutSafetyMarginSec (default 2 seconds) on all platforms.

Changed

  • Android and iOS honor the manifest's polling cadence.

[0.11.0] - 2026-06-12

No customer-facing changes.

[0.10.0] - 2026-06-12

Added

  • Tune-in support: a viewer who joins or seeks into a running break sees its remainder.

[0.9.0] - 2026-06-12

Added

  • Android TV demo app.
  • Android and iOS: JSON export of diagnostics reports.

[0.8.4] - 2026-06-12

Changed

  • Documentation: platform switcher in the demo docs.

[0.8.3] - 2026-06-12

Changed

  • Documentation: per-SDK sections and multi-SDK how-to pages.

[0.8.2] - 2026-06-12

Changed

  • Documentation: reorganized navigation.

[0.8.1] - 2026-06-12

Changed

  • Documentation: Android and iOS onboarding pages.

[0.8.0] - 2026-06-12

Added

  • Android SDK: Kotlin core, SDK, runtime with ExoPlayerAdapter, GAM pod serving, and demo app.
  • iOS/tvOS SDK: Swift core, SDK, runtime with AVPlayerAdapter, GAM pod serving, and demo app.

Changed

  • Android: the runtime module is renamed to dolbyads-runtime.
  • Documentation covers the native platforms.

[0.7.0] - 2026-06-11

Added

  • AI onboarding agent and quickstart bootstrapper in @dolby-ads/sdk.

[0.6.0] - 2026-06-10

Added

  • iPhone (iOS 17.1+): adaptive ad-insertion mode via Managed Media Source; auto resolves to it when available.
  • detectMediaSourceCapabilities() helper and MediaSourceCapabilities type.

[0.5.0] - 2026-06-09

Added

  • Structured diagnostics: onDiagnostic(), offDiagnostic(), diagnose(), and exportDiagnostics().
  • Consecutive-break chaining via the chaining option.
  • @dolby-ads/mcp server and dolby-ads-init-ai CLI for AI assistants.
  • @dolby-ads/adapter-test-kit conformance suite for player adapters.
  • Demo: AI Troubleshooting panel and AI Assistant documentation.

Fixed

  • Overlapping ad breaks are suppressed.

[0.4.0] - 2026-06-09

Added

  • adInsertion option ('overlay', 'shared-element', or 'auto') enabling iPhone Safari support.
  • adPreload option ('parallel', 'single-decoder', or 'auto').
  • @dolby-ads/sdk entry package with an HLS.js ad player and native <video> fallback.
  • Optional preload() and supportsParallelBuffering members on PlayerAdapter.
  • Native video adapter derives Program Date Time on iPhone.

Changed

  • createAdAdapter is optional; @dolby-ads/core throws a clear error when it is omitted.

[0.2.0] - 2026-06-09

Added

  • waiting and playing events for content and ad playback with a source field.

Changed

  • aderror covers content playback errors too, distinguished by source.

[0.1.0]

Added

  • Initial release: @dolby-ads/core with the PlayerAdapter interface, @dolby-ads/adapter-hlsjs, break manifest polling, and static HLS ad insertion.