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.
Architecture
Key concepts
- Internal ad player — the SDK creates its own
<video>element and plays ads through it. With@dolby-optiview/ads-sdkthe 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, socreateAdAdapteris optional; with bare@dolby-optiview/ads-sdk-coreyou supply acreateAdAdapterfactory. - Two-container DOM —
containeris the outer SDK stage (anchors the ad overlay and companions);playerContainerwraps 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*.mdartifacts 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.
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:
DA-SESSION-STARTED— the session is polling the break manifest.adbreakbegin— an ad break starts (content pauses, ad overlay shows).adbegin→ quartiles →adend— ad creative plays.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,
});
@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.
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
Hlsinstance for content. The ad HLS instance is created insidecreateAdAdapter. - 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-TIMEtags 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.
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 anyshaka.Playerinstance
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.
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).
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:
GEOBframes are decoded by the adapter, not by Shaka.shaka.util.Id3Utilshas noGEOBdecoder, 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 realdescription(Anvatos),mimeType(application/json) and payload (type=cue&pts=…). Players that already decodeGEOBare 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
metadataaddedgives the adapter the same parse-time delivery HLS.js has.
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
libraryLocationto serve THEOplayer's worker/WASM files — use a CDN or copy fromnode_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:
createAdAdaptermust honour thesharedVideoargument as shown above. AChromelessPlayercreated 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.
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
STATICcreative (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 withmanifestParsingError); - 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 viavideo.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()returnstrueand HLS playlist creatives play through theHlsJsAdapter(full ABR, quality/track selection, precise buffering); progressive MP4 creatives still useNativeVideoAdapter. On older iOS and other MSE-less runtimes everything falls back toNativeVideoAdapter.
// 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
NativeVideoAdapteralso backs the demo's "Native HLS" content player option. The demo auto-selects it for the content<video>wheneverHls.isSupported()isfalse(the MSE-less tail: iPhone Safari < 17.1, older iOS/tvOS WebViews) so content still plays viavideo.src; on MSE/MMS-capable runtimes hls.js stays the default. You can also force it via the?player=nativeURL 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'swebkitDisplayingFullscreenstate /webkitbeginfullscreen/webkitendfullscreenevents. - 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 andlshape_contentis 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);
});
}
}
•
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.
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.
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"))
}
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. |
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) }
}
}
•
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-iosfor iOS,…-google-interactive-media-ads-tvosfor 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>'
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"))
}
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.
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 theAVPlayerLayerpresenting your content and runs its ownAVPictureInPictureControlleragainst 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.
.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) }
}
}
•
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.
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. |
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.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.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. |
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. |
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.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/adbreakendevents 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. |
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
minBreakDurationSecondsof the break remain, the break is triggered for its remaining duration. Theadbreakbeginevent carries atuneIn: { 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
minBreakDurationSecondsremain, 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. |
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:
- creates the IMA DAI stream (for a
stream_id), - builds the stitcher master URL and loads it into the content player,
- forwards in-stream
timedmetadatacues to IMA for ad tracking, and - 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:
interceptManifestRequestruns before the network call (initial fetch + every poll). Return aManifestRequestto redirect the URL or add headers, aManifestMockResponseto 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).interceptManifestResponseruns after fetch + validation, handing you the parsed, validatedBreakManifestso 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. |
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.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.
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:
- A string replaces the token.
nullmarks the macro empty. The parameter is not sent. For GAMcust_params, only the&-separated pair containing the macro is dropped. If no pair remains, thecust_paramskey is omitted. For VAST, the wholecust_paramsparameter is omitted.undefinedor 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:
- A customer macro wins over a built-in
$OPTIVIEW_*name. - Otherwise the built-in value is used.
- A macro that resolves to
nullomits its parameter. For GAMcust_params, only the pair containing the macro is dropped. For VAST, the wholecust_paramsparameter is omitted. - An unresolved
$OPTIVIEW_*name (unknown, or a registration that is / returnsundefined) is kept literally in the value; the parameter is still sent and emitsDA-ASSET-MACRO-UNKNOWNonce per parameter key and macro (context:key,macro). - 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';
});
@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).
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.
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.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';
});
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).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.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.
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 ofvideo.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 settingvideo.currentTimedirectly — honourssnapbackenforcement. Direct player seeks are also corrected once without recursively re-seeking when the correction emits anotherseekedevent. - Drive your countdown from
adbreakstatus— the SDK emits this event whenever the break state or countdown changes. Usesdk.getAdBreakStatus()at any time for the current status, or subscribe toadbreakstatusfor live updates. Thestatusobject includesphase('idle' | 'upcoming' | 'active' | 'complete'),secondsUntilBreak,breakRemainingSec,adsRemaining,adIndex,totalAds, andticking.adsRemainingcounts the currently playing ad while an ad plays (totalAds − adIndex) and the ads still to come after anadend(totalAds − adIndex − 1, so0after the last ad).breakRemainingSecfollows 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. breakWarningsconfig controls pre-break warnings. SetbreakWarnings: { seconds: [10, 5] }to receiveadbreakstatuswithphase: 'upcoming'at 10s and 5s before the break.- Dismiss the countdown on
adbreakend—adbreakendfires 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).
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). |
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`));
});
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)).
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.
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— aFrameLayoutmatching the content bounds, passed toOverlayAdRenderer(context, overlayContainer, contentAdapter). - Route playback through the SDK — call
sdk.play()/sdk.pause()/sdk.seek(seconds)instead of touching theExoPlayerdirectly, so content-lock andsnapbackare honoured. - Swap controls for a break indicator on
ADBREAKBEGIN/ADBREAKEND. - Drive a countdown from the
ADTIMEUPDATEevent (currentTime/duration) or frombreak_.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()
}
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
UIViewmatching the content bounds, passed toOverlayAdRenderer(overlayContainer:contentPlayer:). - Route playback through the SDK — call
sdk.play()/sdk.pause()/sdk.seek(seconds)instead of touching theAVPlayerdirectly. - Swap controls for a break indicator on
.adbreakbegin/.adbreakend. - Drive a countdown from the
.adtimeupdateevent, 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)
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. |
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
aderrorfor that asset and ignores it (noadbegin); 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
pollingobject — 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 withouttargetingis the default. Absentdeliveryor no matching rule resolvessgai(the current behaviour). An unrecognizedmodevalue resolvessgaiand emitsDA-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 logDA-DELIVERY-MODE-CHANGE-IGNOREDwhen the rules differ. Everything else in a polled manifest (breaks,polling, vendor configuration) is honored normally. - An explicit constructor
modeoverrides manifest steering (DA-DELIVERY-MODE-OVERRIDDEN) — explicit client configuration always wins. - Manifest-steered
ssairequires a vendor configuration able to provide the stitched stream — currently agamentry carrying anassetKey(the channel's full-service DAI livestream identifier, distinct from the pod-servingcustomAssetKey). 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
ssaiis selected but no vendor configuration provides a stitched stream,startSessionrejects with the catchableSsaiUnavailableError(DA-SSAI-UNAVAILABLE) — there is no silent fallback tosgai; 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 equivalentSsaiUnavailableException. iOS / tvOS still resolves every session assgai; 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
resumeOffsetor 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 owndelay>=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
channelIdis 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 whilechannelIdstays the same (reason:vendor-configuration-changed); - the
timebasechanges whilechannelIdstays 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-FAILEDis 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/assetParameterMacrosand any runtimeupdateAssetParameters()overrides; both are re-applied to a re-opened GAM session. - The delivery mode is resolved again from the new manifest. An
sgaitossaiswitch loads the stitched stream into the player. If no stitched stream is available,DA-SSAI-UNAVAILABLEis logged and the session stays insgai. Anssaitosgaiswitch stops the stitched stream; the SDK does not load a content stream, so the application must load it afteradchannelchange(previousDeliveryMode: 'ssai',deliveryMode: 'sgai'). Explicit constructormodestill 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 (
delay0) holds content until the ad starts (see Session). A delayed pre-roll starts counting itsdelaywhen the first content frame plays, not when the session is created. adStartDelayapplies: a pre-roll plays only when itsdelay>=adStartDelay.resumeOffsetapplies;controls.snapbackis ignored for event-triggered breaks.- The
startandendevents are scheduled on every platform (see Post-roll breaks); thepauseevent drives pause ads. A break whosestart.eventis an unknown value is ignored and reported once asDA-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-leveldelay(and a placeholderstart: 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 (defaulthttps://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 (
Unavailableuntil the adapter receives a validEXT-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
interceptManifestResponsehook: the callback receives the parsed, validatedBreakManifest(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
delaystarts 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. adStartDelayapplies, judged on the effective start moment (content end plusdelay): a post-roll is not started while the ad-free start is still active.durationis the maximum duration of the break. Withoutcontrols.skipOffsetthe break is watched fully; with it the viewer can end the break once the offset is reached.resumeOffsetandcontrols.snapbackare ignored. After the break, the content stays at its end position and is not restarted: the SDK does not seek or callplay(). 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
ssaimode, 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
vastpre-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):
- Device-type match. A variant qualifies when its
targeting.deviceTypematches the detected device class (desktop/mobile/tablet/tv— the same detection used for per-URI asset targeting). A variant withouttargetingis a default and qualifies on any device. - 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 thesinglefallback). A variant with an unknown format is never playable. While content is in picture-in-picture at break start the playable set narrows tosingle, so a declaredsinglevariant 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
variantis an array — e.g. atv-targeteddoublefirst and an untargetedsinglebehind it — and play it on the Player page: a desktop browser plays thesingle, and a break whose only variant targetstvnever begins (watch theaderror
DA-BREAK-NO-PLAYABLE-ASSETin the event log).
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.
interceptManifestRequestruns before the network request (rewrite URL, add headers, or mock the response).interceptManifestResponseruns after fetch + validation (transform the parsedBreakManifest).
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 usebody(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, andinterceptManifestResponse(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) }),
});
(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(normalizedvariantsarrays, 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)
BreakManifestto 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-FAILEDdiagnostic 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,
],
};
},
});
(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.
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
- A Google Ad Manager account with DAI Pod Serving enabled.
- A Network Code and a Custom Asset Key per channel.
- 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:
- The session —
SessionConfig.assetParameters, supplied atstartSession(). - A live update —
updateAssetParameters()orupdateAssetParameterMacros(). - The manifest session —
vendorConfiguration.gam.sgai[0].assetParameters. - 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:
- Uses the IMA
StreamManager(initialized atstartSession()) to build a pod manifest URL from the stream ID and pod ID. - Loads that URL into the ad player (preloaded 5 seconds before break start).
- Pauses content and plays the ad overlay at break time.
- 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.
AI Assistance
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.
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 withpts_adjustmentapplied and converted from 90 kHz ticks to seconds, out-of-network / immediate / auto-return flags, break duration, segmentation descriptors (type name, UPID) and theCRC_32check; - a pasted HLS tag line —
#EXT-X-DATERANGE:…,SCTE35-OUT=0xFC30…has its binary section extracted and decoded;#EXT-X-CUE-OUT:38.4carries 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
emsgidentified by itsschemeIdUri, or a baretype=cue&pts=1234.567payload.
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
- Reproduce the issue with the SDK running.
- Capture a redacted report:
const report = sdk.exportDiagnostics();(no ad-tag-parameter values or secrets are ever included — see Diagnostics). - 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. - You get a root cause and concrete fix steps.
- 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_configwith that device's User-Agent and youradPreload/adInsertion/modevalues:autoresolves 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, andoverlayformats, 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.
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-stitcherserver 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.
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
singlead. By default it also stitchesdoubleandlshape_adas fullscreen single (companion dropped);lshape_contentandoverlayneed 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.
- The client creates the Google DAI stream (IMA
PodStreamRequest) and gets astreamId, 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. - 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
buildGamPodUrlthe client SDKs use (passing the client'sstreamId), and splices the pod's segments in — wrapped inEXT-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 forgambreaks; absent → those breaks are skipped).max_bitrate— optional (bits/sec, master only). Drops master variants whoseBANDWIDTHexceeds 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(cumulativeEXTINF) andwallclock(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
PlayerAdaptermust exposevideoElement(IMA DAI binds to a real<video>). HLS.js, Shaka, and the native-video adapter qualify. mode: 'ssai'requiresgam.networkCode;startSessionrequiresstitcherUrl(the full master-playlist URL, in place ofmanifestUrl) andcustomAssetKey. The demo composes that URL from its Stitcher Base URL field plus the org/channel pickers.customAssetKey/assetParameterscome fromstartSessionfor explicit SSAI. Manifest-steered SSAI also takesssai[0].assetParametersas the manifest session layer. The precedence is session < update < manifest; a manifest parameter cannot be overridden byupdateAssetParameters().- Failures surface as
DA-SSAI-SESSION-FAILED(startup) andDA-SSAI-IMA-ERROR(in-stream); ad lifecycle is emitted as the usualadbreakbegin/adbegin/ quartiles /adend/adbreakendevents.
Parity: the portable
buildStitcherMasterUrlis 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:
- Click Start Stitcher — a Vite dev middleware spawns a local
@dolby-optiview/ads-sdk-stitcherconfigured 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. - (optional) Set Max Bitrate to cap the rendition bitrate — the stitcher drops higher variants from the master playlist.
- Click Load — the SDK creates the IMA stream, loads the stitched master, and (with Autoplay on) plays it.
- 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:
- Enter the server base URL and org ID.
- Click Start Server to launch the server via the Vite dev proxy.
- Paste a break manifest JSON and click Create Endpoint.
- The created URL is displayed and can be used in the player configuration.
- 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.
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
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
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).
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.ktsdependencyResolutionManagementform shown in the Android section above. For a React Native app, add the repositories to the rootandroid/build.gradleproject'sallprojectsblock 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
/releasesrepository 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 withCould not find com.dolby.optiview:.... Keep the content filter: without it, Gradle may probe this static repository for unrelated dependencies and receive403 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-theoplayeris optional;react-native-videov6 uses the bridge's Media3 lookup without a compile-time dependency on that package.iOS: add the
OptiViewAdsReactNativepod through React Native autolinking. Its podspec can declare the native OptiView Ads pods it needs —OptiViewAdsRuntimeOptiViewAdsReactBridge(and, only when the app also shipsreact-native-theoplayer,OptiViewAdsAdapterTHEOplayer) — sopod installpulls them (and transitivelyOptiViewAdsSDK/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 withOPTIVIEW_ADS_NATIVE_PODS=1in thepod installenvironment, until the pods are published to the Specs repo (first release afterPUBLISH_COCOAPODS_ENABLEDgoes live — without a resolvable source the dependency would fail everypod install). With the gate on, the host Podfile only needs the Specs-reposourceline (next to the default CDN — see the CocoaPods snippet above).react-native-videov6 needs no THEOplayer package; when usingreact-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-theoplayerorreact-native-videov6.
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 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:
VastAdManager.preloadVast(adTagUrl, options)runs when the break approaches (PRELOAD_AHEAD_SECONDS = 8). It fetches and parses the VAST tag and creates the IMAAdsManager, but does not start playback.VastAdManager.startPreloaded()runs at break start. Before it starts the heldAdsManager, it callsresize()to re-read the ad<video>element's dimensions against the layout that is now applied (double/lshape_adbox, etc.). This corrects the slot size thatpreloadVastcaptured 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:
- Open the VAST page (sidebar → VAST).
- 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). - 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. - 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/adbreakendflow.
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.
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-leveldelayas the pause delay,format: "pause"/"pause_image") is no longer parsed. Migrate: replaceposition: "pause"with thestartobject above, movedelayintostart.delay, and change the variantformattosingle.
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 nativePlayerAdapters 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:
- 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. - A demo page —
<customer>.html+packages/demo/src/<customer>.js, registered as a Vite input inpackages/demo/vite.config.mjs. - The shared shell (
packages/demo/src/customer-demo/shell.ts) mounted at the top of the page. - 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 Servernode. 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 verticalContentarrow is one straight line. The Ads SDK'sAd callarrow 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, labelledBreak 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 —
1Dashboard (configure),2Origin (connect your stream),3Player (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 (seeTHEO_LIBRARY_LOCATIONinpackages/demo/src/player-factory.js) — if it points at a different version, transmux fails silently. Keep the configured library location and the installedtheoplayerpackage 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-bridgeparser, and returns awallclockbreak manifest. Bally's carries no SCTE-35 binary payload and noEXT-X-DATERANGE, so the break start comes from the nearest#EXT-X-PROGRAM-DATE-TIMEand the duration from theDURATIONvalue. The manifest is handed to the SDK viainterceptManifestRequestreturning{ 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()inpackages/demo/src/customer-demo/publica-tag.ts, against Bally's Publica endpointhttps://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 byfetchPublicIp(); omitted if the lookup fails),uafromnavigator.userAgent, a stablesession_id/did, a freshcbcache-buster per request, andpod_duration— the break's length in milliseconds — so Publica sizes the pod to the break. GAM'spmnd/pmxdpod params are not used here (Publica ignores them);vast-pod.tsstill 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 frominterceptManifestRequest; it takes abuildVastTag(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 usesposition: 'pre', pause usesstart: { type: 'event', event: 'pause' }with asinglevariant. 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'sprogramDateTime, using the sharedclampSeekTargethelper) 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-fatalDA-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.htmlloaded fromlibraryLocation. A cross-originlibraryLocation(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.mjsstages the installed build intopublic/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 everypod_duration(30000/90000/150000) and for stale IPs, so neither the pod length nor theipwas the cause.nextSessionId()therefore issues a freshsession_id/didper break (sharing one monotonic counter withcb, 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 byDISCLAIMER_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.hintparagraphs 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;titleandsummaryare escaped,bodyHtmlis trusted in-repo markup (<code>,<strong>). - An empty VAST is a no-fill, not an SDK error. Publica answers
200with<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 asDA-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: aNO AD FILLnotice toast over the player (amber badge, below the break-countdown pill, auto-hiding after 8s) and aNO 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 withcurl. 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 exposesstart/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 streamhttps://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
manifestBaseUrlpointing 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/injectPausehelpers (see "Injecting pre-roll / pause on a live backend" above) viainterceptManifestResponse. 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:
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
manifestBaseUrlpointing 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/pmxdsized 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.pngfor Double,bell-lbar.pngfor 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
#0065a4accent, 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'smanifestBaseUrl+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 tointerceptManifestRequest: 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 logopublic/players/theo.svg), so the viewer picks HLS.js, Shaka, or THEOplayer; the page passestheoPlayerEltocreateContentPlayerthe 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.pngandpublic/nba/nba-lbar.png(1920×1080; SVG masters underdocs/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/injectPauseviainterceptManifestResponse): 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-hostedpublic/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;
setConfigadditionally accepts{ source: 'llhls' | 'vod', vodManifestUrl }.
Adding a new customer demo
- Add a
CustomerDemoentry toCUSTOMER_DEMOSinpackages/demo/src/customer-demo/registry.ts(id,name,description,href,brand). The portal tile appears automatically, with the name shown in uppercase. - Create
<customer>.htmlandpackages/demo/src/<customer>.js; mount the shell and wire the SDK for the customer's stream. - Mount the shared explainer (
mountExplainerfrom./customer-demo/explainer, see above) below the demo markup, with the customer'scustomerNameandwiredBullets. - Register the page as a Vite input in
packages/demo/vite.config.mjs. - 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).secondsUntilBreakis the live countdown to the break.active— a break is playing. Show the badge; render the countdown frombreakRemainingSecand the counter fromadIndex/totalAds.complete— the break finished. Dismiss the break UI (mirrorsadbreakend).
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();
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 (
[]), noupcomingwarnings fire; you still get the fullactivecountdown.
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
upcomingwarning — a pre-roll has no pre-break content, so the first status you see for it isphase: 'active'.tickinggates the first frame — for a held/first-frame-gated pre-roll, the status is emitted withticking: falseuntil the ad's first frame renders. Render a static "Ad break" badge whiletickingisfalse, and switch to the livebreakRemainingSeccountdown once it flips totrue. This avoids showing a countdown against a frame that has not started.Monotonic countdown — once ticking,
breakRemainingSeccounts down smoothly to0(including CSAI/VAST pre-rolls, which advance from IMA ad progress). It never rewinds.Dismiss on
complete/adbreakend— hide the badge whenphasebecomescomplete(or on theadbreakendevent).A short, healthy pre-roll can complete extremely fast.
startSession()starts the break scheduler's 250ms tick and returns; adelay: 0pre-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 fromawait sdk.startSession(...)and gets around to attaching listeners or pollingsdk.getAdBreakStatus(). Registeradbreakstatus/ad-event listeners before callingstartSession, 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-tvosrelease through the npm alias"react-native": "npm:react-native-tvos@<matching-version>". - A supported host player:
react-native-theoplayerorreact-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
OptiViewAdsSDKSwift 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 aOptiViewAdsConnectorwithstartSession/endSession,addEventListener/removeEventListener,seek(time)(routed through the SDK's seek policy — suppressed during breaks withcontrols.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, anddestroy().skipAd()is asynchronous and resolves totrueonly when the active break'scontrols.skipOffsetpolicy permits the skip.clickAd()resolves to the click-through URL (ornull); it never navigates. The host application can use the returned URL with its own navigation policy.The config is the web
OptiViewAdsConfigminus its DOM/function members; events reuse the webOptiViewAdsEventMapverbatim, and live diagnostics reuse the webDiagnosticEventshape: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 layervendorConfiguration.gam.sgai[0].assetParametersorssai[0].assetParameters, andassetParameterson 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 sameSessionConfig.assetParameterMacrosmap 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,
nullmarks it empty, andundefinedleaves 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 raiseDA-ASSET-MACRO-UNKNOWNonce per parameter key and macro, other names stay literal without a diagnostic. A null macro omits its parameter; for GAMcust_params, only the containing&-separated pair is dropped, and the key is omitted if no pair remains. For VAST, the wholecust_paramsparameter is omitted. This raisesDA-ASSET-PARAMETER-OMITTED.updateAssetParameterMacros(macros)merges names during playback;nullmarks a name empty andundefinedunregisters it so the built-in macro applies again, and new values apply to the next ad request. RN values are strings or callbacks likeassetParameterMacros. 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 byendSession().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.adInsertionTypeselects 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. EnablenewArchEnabled=true(Androidgradle.properties) /RCT_NEW_ARCH_ENABLED=1(iOS pod install).- RN 0.87 iOS fails on
React/RCTRootContentView.hfromreact-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 appliesscripts/patch-react-native-theoplayer-ios.cjsduringpostinstall; 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 afile:link inside the monorepo, Metro must watch the package folder; seereact-native/demo/metro.config.jsfor the reference configuration.- Android dependency resolution fails — register THEOplayer
/releasesfor stable versions; develop/custom builds also need their versioned artifact-host repository. Useallprojects.repositoriesas above, keepgoogle()/mavenCentral(), and enable desugaring. - Player never resolves —
createOptiViewAdsneeds the view tag of the mounted player view:player.nativeHandlefromonPlayerReadyforreact-native-theoplayer(not before mount), or theuseReactVideoHandle()helper forreact-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:
OptiViewAdsand 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,adendand quartile events for the ads of a GAM pod now carry the per-adasset, 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
startis{ "type": "event", "event": "pause", "delay": <seconds> }with a standard variant (for examplesingle) holding an image or video asset. Several pause breaks rotate in manifest order, one per pause, and wrap to the first when exhausted;start.delayis the time the content must stay paused before the creative appears;controls.skipOffsetkeeps 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-leveldelayused as the pause delay, and the variant formatspauseandpause_imageare no longer recognised;BreakPositionis now only"pre". Migrate tostart: { "type": "event", "event": "pause", "delay": <seconds> }with asinglevariant; the MCPvalidate_manifesttool 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, andPlayerAdapter) underdocs/api/.
[0.52.0] - 2026-09-04
Changed
- BREAKING:
useAnvatoID3is replaced byptsSource. UseptsSource: 'anvatoCue'on web,PtsSource.ANVATO_CUEon Android,.anvatoCueon iOS/tvOS, or"ptsSource": "anvatoCue"in React Native for Anvato cue signaling; the defaultmediaTimekeeps the previous behavior. - Diagnostic reports expose
config.ptsSourceinstead ofconfig.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
continueContentDuringBreakkeeps content playing hidden and muted behind eligible replacement breaks (defaultfalse). - Break Manifest 1.3.0 asset parameters:
vastassets can carryassetParameters,$OPTIVIEW_*macros are expanded, andSessionConfig.assetParameterMacrosregisters 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-INVALIDand schedules no breaks. - Manifest-driven delivery steering: the manifest's
deliveryrules 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
emsgon 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, andDA-GAM-POD-IDENTITY-MISMATCH. - Android and iOS/tvOS:
AdScaling(fitorfill) controls how ad creatives fill the ad surface. - Android: audio-focus diagnostics
DA-AD-FOCUS-GRANTED,DA-AD-FOCUS-DENIED, andDA-AD-FOCUS-ABANDONED. - Android: Lens reports carry the device class and a persistent user ID.
- React Native:
connector.seek(time),connector.getPresentationState(), andconnector.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, andvalidate_break_manifesttools;scaffold_quickstartcovers SSAI and native platforms;explain_event_timelinereports per break.
Changed
- BREAKING: npm packages renamed to
@dolby-optiview/ads-sdkand@dolby-optiview/ads-sdk-<name>, Maven coordinates tocom.dolby.optiview:ads-sdk*, and the SwiftPM package tooptiview-ads-sdk. No compatibility aliases. - BREAKING:
PlayerAdapter.liveSyncPositionrenamed tomaxLiveSeekPositionin all cores and adapters. - BREAKING: GAM is activated by the break manifest's
vendorConfiguration;GamConfig.enabledis 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. adPreloaddefaults to'auto', and VIZIO SmartCast TVs are recognized as single-decoder devices.- CMCD
adposreports the 1-based position within the pod, platform and device-class tokens are abbreviated, and the manifest'sorganizationIdis reported asadoid. - The Lens SDK is bundled into the published artifacts; installing the SDK needs no private registry.
- A break variant with an unknown or missing
formatis skipped and reported. - A break with nothing to play no longer emits an empty
adbreakbegin/adbreakendpair. - 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.networkCodeandSessionConfig.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, andaderas bare tokens and reports correct timing, duration, and vendor error codes. - Every network read is bounded by a timeout on all platforms.
destroy()orendSession()during an in-flightstartSession()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
adbreakbeginwhen the IMA stream session is unavailable. - Web:
destroy()silences late diagnostics. - Web: the Shaka adapter reports
programDateTimeasnullfor live streams withoutEXT-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_manifestreports accurate results. - Demo: SSAI sessions start again.
[0.50.0] - 2026-08-17
Added
- React Native:
skipAd()andclickAd()viewer controls.
Changed
- BREAKING:
DolbyAdsis renamed toOptiViewAdsacross every platform, with no compatibility aliases. - BREAKING:
SessionConfig.manifestUrlis the only manifest input and is fetched verbatim;orgId,manifestBaseUrl, andchannelIdare removed. - BREAKING:
assetParametersandupdateAssetParameters()replaceadTagParametersandupdateAdTagParameters(); per-asset manifest parameters are applied and restored at break end. - BREAKING: SSAI sessions pass
startSession({ stitcherUrl });stitcherBaseUrlis removed. - The diagnostics report renames
gam.hasAdTagParameterstogam.hasAssetParametersand reportsmanifestOrigininstead oforgId/manifestBaseUrl. - MCP
scaffold_quickstarttakesmanifestUrlinstead oforgIdandchannelId.
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:
doublebreaks 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-STALLEDand 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_quickstartscaffolds React Native integrations forreact-native-theoplayerandreact-native-video. - iOS: break transitions reach Android parity with first-frame-gated cross-fade, dissolve at break end, deferred content pause, and the
transitionconfig. - 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-theoplayerwhen onlyreact-native-videois 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
pollingobject 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-videois supported as a second host player, with theuseReactVideoHandle()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 asDA-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-TRANSITIONreports 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
staticassets. - 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-TARGETDURATIONis raised when spliced ad segments exceed the origin's target duration.
[0.45.0] - 2026-08-04
Added
- React Native:
@dolby-ads/react-nativewithcreateDolbyAds(player, config), Android and iOS bridges forreact-native-theoplayer, and a react-native-web runtime. - Web:
DA-ANVATO-CUE-OBSERVEDreports every Anvato break-signaling cue observed in the stream whenuseAnvatoID3is 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-ENDEDtwice or accepts late manifest polls. - Android: a multi-ad GAM pod reports one
adbegin/adendpair per ad.
[0.44.0] - 2026-08-03
Added
pdtGraceSecondsconfigures 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-RESOLVEDandDA-PRELOAD-PATH. - iOS: a multi-ad GAM pod reports one
adbegin/adendpair per ad.
Fixed
- Android:
playingis 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/adendpair 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
singlelayout. - 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
singlelayout on web and Android. setPictureInPicture(),isPictureInPicture(), andgetPresentationState()on web and Android.- Shared-element insertion works over MSE.
DA-PIP-TRANSFER-FAILEDreports 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
aderrorinstead 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-TRANSITIONreports 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
ptstimebase.
[0.40.0] - 2026-07-30
Added
DA-BREAK-TRANSITIONmeasures 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
useAnvatoID3option.
[0.38.0] - 2026-07-29
Added
- Android:
skipAd(),clickAd(), and theadclickevent.
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
deviceTypeasset targeting is applied. - Quartile events fire for
staticassets. double,lshape_ad, andlshape_contentbreaks reveal content smoothly instead of cutting to black.
Changed
- Documentation covers the creative CORS requirement, fast pre-roll completion, and DAR
resumeOffsetsemantics.
[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/adbreakendlifecycle. - 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
doubleBoxAudioselects whether the ad or the content is audible during adoublebreak.- Unified mute and volume API for content and ad playback.
- Android and iOS/tvOS:
adbreakstatusevent andgetAdBreakStatus()API, with a countdown driven by real ad playback.
Fixed
- Demo pages: the control bar and break toast keep their size during
doubleand 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
doubleand 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
adbreakstatusevent andgetAdBreakStatus()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-bridgeand 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 byadPlayerFactory.- 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
adendfor the in-flight ad. - GAM pod serving forwards timed metadata to IMA and emits balanced
adbegin/adendevents.
[0.27.0] - 2026-06-27
Added
interceptManifestRequestlets integrators inspect and modify the manifest request before it is sent.
[0.26.0] - 2026-06-27
Added
interceptManifestResponselets integrators inspect and modify the manifest on the client.- Every break and ad event carries a
formatfield. - 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 legacypause_imageis 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-elementinsertion no longer stalls content on non-iOS platforms.
[0.18.0] - 2026-06-17
Added
@dolby-ads/break-manifest-serverstores and serves static break manifests.overlaybreak 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_bitraterendition filtering. timedmetadatacapability on thePlayerAdaptercontract.- Diagnostic codes
DA-SSAI-SESSION-FAILEDandDA-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:
doubleandlshape_adbreaks are stitched as a fullscreensinglead.
[0.13.0] - 2026-06-12
Added
@dolby-ads/stitcher: server-side ad stitcher forsinglebreaks.@dolby-ads/core/serverentry 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
pollingcadence.
[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+):
adaptivead-insertion mode via Managed Media Source;autoresolves to it when available. detectMediaSourceCapabilities()helper andMediaSourceCapabilitiestype.
[0.5.0] - 2026-06-09
Added
- Structured diagnostics:
onDiagnostic(),offDiagnostic(),diagnose(), andexportDiagnostics(). - Consecutive-break chaining via the
chainingoption. @dolby-ads/mcpserver anddolby-ads-init-aiCLI for AI assistants.@dolby-ads/adapter-test-kitconformance 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
adInsertionoption ('overlay','shared-element', or'auto') enabling iPhone Safari support.adPreloadoption ('parallel','single-decoder', or'auto').@dolby-ads/sdkentry package with an HLS.js ad player and native<video>fallback.- Optional
preload()andsupportsParallelBufferingmembers onPlayerAdapter. - Native video adapter derives Program Date Time on iPhone.
Changed
createAdAdapteris optional;@dolby-ads/corethrows a clear error when it is omitted.
[0.2.0] - 2026-06-09
Added
waitingandplayingevents for content and ad playback with asourcefield.
Changed
aderrorcovers content playback errors too, distinguished bysource.
[0.1.0]
Added
- Initial release:
@dolby-ads/corewith thePlayerAdapterinterface,@dolby-ads/adapter-hlsjs, break manifest polling, and static HLS ad insertion.