Dolby 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-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-ads/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
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-ads/sdk hls.js
The @dolby-ads/sdk entry package defaults the ad player to HLS.js (with a
native <video> fallback for MSE-less platforms such as Safari/iOS).
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 { DolbyAds, HlsJsAdapter } from '@dolby-ads/sdk';
const video = document.getElementById('video') as HTMLVideoElement;
const hls = new Hls();
hls.attachMedia(video);
Other supported adapters: @dolby-ads/adapter-shaka (Shaka Player),
@dolby-ads/adapter-theoplayer (THEOplayer), or NativeVideoAdapter
(built into @dolby-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 DolbyAds({
orgId: 'your-org-id',
player: new HlsJsAdapter(hls, video),
container: document.getElementById('container'),
debug: true,
});
With @dolby-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({ channelId: 'your-channel-id' });
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-ads/core
Use the bare @dolby-ads/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-ads/core @dolby-ads/adapter-hlsjs hls.js
import Hls from 'hls.js';
import { DolbyAds } from '@dolby-ads/core';
import { HlsJsAdapter } from '@dolby-ads/adapter-hlsjs';
const video = document.getElementById('video') as HTMLVideoElement;
const hls = new Hls();
hls.attachMedia(video);
const sdk = new DolbyAds({
orgId: 'your-org-id',
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-ads/core throws if createAdAdapter is omitted (it has no default). Use @dolby-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 DolbyAds.version accessor — no instance required. It is the same lockstep version across @dolby-ads/core and @dolby-ads/sdk (and matches the Android/iOS SDKs and the stitcher's GET /version).
import { DolbyAds } from '@dolby-ads/sdk'; // or '@dolby-ads/core'
console.log(DolbyAds.version); // e.g. "0.16.0"
Player Adapters
The SDK core (@dolby-ads/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.
PlayerAdapter Interface
Any adapter must implement all properties and methods below. The interface is imported from @dolby-ads/core.
import type { PlayerAdapter, PlayerAdapterEvent, PlayerAdapterEventHandler } from '@dolby-ads/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;
// ── optional ──────────────────────────────────────────────────────────────
/** Stop driving the `<video>` without tearing the player down, so the SDK can
* play an ad through that same element (shared-element insertion). 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>;
}
| 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. Do not tear the player down — the SDK calls load() on the same adapter to give the element back. |
HLS.js Adapter
The official HLS.js adapter is available as a separate package.
Install
npm install @dolby-ads/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 { DolbyAds } from '@dolby-ads/core';
import { HlsJsAdapter } from '@dolby-ads/adapter-hlsjs';
const video = document.getElementById('video') as HTMLVideoElement;
const hls = new Hls();
hls.attachMedia(video);
const sdk = new DolbyAds({
orgId: 'your-org-id',
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: { networkCode: '23285652104' },
});
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({
channelId: 'your-channel-id',
customAssetKey: 'your-asset-key',
});
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-ads/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 { DolbyAds } from '@dolby-ads/core';
import { ShakaAdapter } from '@dolby-ads/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 DolbyAds({
orgId: 'your-org-id',
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({ channelId: 'your-channel-id' });
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.getPresentationStartTime() combined with video.currentTime. This is used to match wallclock-timebase breaks.
null for VOD content and live streams without absolute time references.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 useAnvatoID3 is enabled (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 |
Each marker is emitted once, de-duplicated across metadataadded and metadata, so IMA never receives a doubled tracking cue.
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.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-ads/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 { DolbyAds } from '@dolby-ads/core';
import { THEOplayerAdapter } from '@dolby-ads/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 DolbyAds({
orgId: 'your-org-id',
player: new THEOplayerAdapter(player, playerDiv),
container: document.getElementById('container'),
createAdAdapter: (adContainer) => {
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({ channelId: 'your-channel-id' });
player.source = { sources: [{ src: 'https://example.com/live.m3u8' }] };
player.play();
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-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-ads/sdk's default ad adapter, per creative at load() time:
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-ads/sdk and it is selected for you. Import it explicitly only if you build a custom factory:
import { NativeVideoAdapter } from '@dolby-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-ads/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-ads/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.
npm install -D @dolby-ads/adapter-test-kit
// MyPlayerAdapter.conformance.test.ts
import { runAdapterConformance } from '@dolby-ads/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 Dolby 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.
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
// build.gradle.kts (app module) — PREVIEW coordinates
dependencies {
implementation("com.dolby.ads:dolbyads-runtime:<version>") // ExoPlayerAdapter + OverlayAdRenderer + GAM/IMA
// dolbyads-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.ads.runtime.ExoPlayerAdapter
import com.dolby.ads.runtime.OverlayAdRenderer
import com.dolby.ads.sdk.CoroutineSchedulerTicker
import com.dolby.ads.sdk.DolbyAds
import com.dolby.ads.sdk.DolbyAdsConfig
import com.dolby.ads.sdk.DolbyAdsEventType
import com.dolby.ads.sdk.HttpManifestSource
import com.dolby.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 = DolbyAds(
config = DolbyAdsConfig(orgId = "your-org-id", player = contentAdapter, debug = true),
renderer = renderer,
manifestSource = manifestSource,
ticker = ticker,
scope = scope,
)
// 4. Listen to events.
sdk.addEventListener(DolbyAdsEventType.ADBREAKBEGIN) { e -> Log.d("Ads", "Break started: ${e.breakId}") }
sdk.addEventListener(DolbyAdsEventType.ADBREAKEND) { e -> Log.d("Ads", "Break ended: ${e.breakId}") }
// 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(channelId = "your-channel-id"))
}
startSession is a suspend function: call it from a coroutine. Call sdk.destroy() (then release your ExoPlayer) when tearing down.Ad formats & overlay rendering
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.
Reading the SDK version
Read the current SDK version from the static DolbyAds.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.ads.sdk.DolbyAds
Log.d("Ads", DolbyAds.version) // e.g. "0.16.0"
Google Ad Manager (pod serving)
Supply a GamConfig to the renderer and a customAssetKey on the session. GAM vendor breaks are served via Google IMA DAI.
import com.dolby.ads.sdk.GamConfig
val gam = GamConfig(networkCode = "23285652104")
val renderer = OverlayAdRenderer(context, overlayContainer, contentAdapter, gamConfig = gam)
val sdk = DolbyAds(
config = DolbyAdsConfig(orgId = "your-org-id", player = contentAdapter, gam = gam),
renderer = renderer,
manifestSource = manifestSource,
ticker = ticker,
scope = scope,
)
scope.launch {
sdk.startSession(SessionConfig(channelId = "your-channel-id", customAssetKey = "your-asset-key"))
}
customAssetKey is provided on the session.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.ads.runtime.ExoPlayerAdapter
val contentPlayer = ExoPlayer.Builder(context).build()
val contentAdapter = ExoPlayerAdapter(contentPlayer)
// → pass as DolbyAdsConfig.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. |
PlayerAdapter Interface
Any content player is integrated by implementing PlayerAdapter from com.dolby.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.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)
/** 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.ads.core.PlayerAdapter
import com.dolby.ads.core.PlayerAdapterEvent
import com.dolby.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.
iOS / tvOS (Swift)
The Dolby 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 DolbyAdsRuntime re-exports the core + SDK layers.
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
Installation
// Package.swift — Swift Package Manager (PREVIEW)
dependencies: [
.package(url: "https://github.com/dolby/dolby-ads-ios.git", from: "<version>"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "DolbyAdsRuntime", package: "dolby-ads-ios"),
]),
]
# Podfile — CocoaPods (PREVIEW)
pod 'DolbyAdsRuntime', '~> <version>'
Quick start
import AVFoundation
import DolbyAdsRuntime // re-exports DolbyAdsCore + DolbyAdsSDK
// 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 = DolbyAds(
config: DolbyAdsConfig(orgId: "your-org-id", 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(channelId: "your-channel-id"))
} catch {
print("startSession failed:", error)
}
}
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
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 DolbyAds.version property — no instance required. It is the same lockstep version as the web and Android SDKs and the stitcher's GET /version.
print(DolbyAds.version) // e.g. "0.16.0"
Google Ad Manager (pod serving)
Supply a GamConfig to the renderer and a customAssetKey on the session. GAM vendor breaks are served via Google IMA DAI.
let gam = GamConfig(networkCode: "23285652104")
let renderer = OverlayAdRenderer(overlayContainer: overlayContainer, contentPlayer: contentAdapter, gamConfig: gam)
let sdk = DolbyAds(
config: DolbyAdsConfig(orgId: "your-org-id", player: contentAdapter, gam: gam),
renderer: renderer,
manifestSource: URLSessionManifestSource(),
ticker: DispatchSchedulerTicker()
)
Task {
try await sdk.startSession(SessionConfig(channelId: "your-channel-id", customAssetKey: "your-asset-key"))
}
customAssetKey is provided on the session.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 DolbyAdsRuntime
let contentPlayer = AVPlayer()
let contentAdapter = AVPlayerAdapter(player: contentPlayer)
// → pass as DolbyAdsConfig.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. |
PlayerAdapter Protocol
Any content player is integrated by conforming to PlayerAdapter from DolbyAdsCore. 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 DolbyAdsCore
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.
SDK Configuration
Configuration is split into two levels: SDK-level (fixed for the app lifetime) and session-level (per channel / content piece).
DolbyAdsConfig + SessionConfig + GamConfig), minus the web-only DOM fields.Web
DolbyAdsConfig constructor
| Property | Type | Required | Description |
|---|---|---|---|
| orgId | string | ✅ | Organization ID from the Optiview Ads platform. |
| 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-ads/core; optional with @dolby-ads/sdk, which defaults to an HLS.js ad player (native <video> fallback). |
| adPreload | 'parallel' | 'single-decoder' | 'auto' |
❌ | Ad preload strategy. Default '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. 'auto' picks one via User-Agent inspection — every Samsung (Tizen) / LG (webOS) TV resolves to 'single-decoder' (PLAYG-319). |
| 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. |
| 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 a break's effective duration to form the hard cut-off at which the SDK always returns to content — regardless of whether the inserted ad media has reached its own end. Default 2. Guarantees an over-long, stalled, or never-ending ad asset can't hold the break open, while giving an ad close to the break length a little grace to finish cleanly. Set to 0 to cut exactly at the break boundary. |
| 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 'sgai' (client-scheduled). 'ssai' plays a pre-stitched stream from the stitcher on the content player (see SSAI mode below). |
| stitcherBaseUrl | string | ❌* | Base URL of the @dolby-ads/stitcher service. *Required when mode: 'ssai'. |
| 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. |
| manifestBaseUrl | string | ❌ | Override the default manifest base URL (SGAI only). |
| 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. |
| useAnvatoID3 | boolean | ❌ | Anvato in-stream break signaling (NFL Channel / NFL Network style). Default false. When true, the SDK tracks Anvato timed-metadata cues from the content player — HLS ID3 GEOB frames with description Anvatos (MIME application/json) and DASH emsg events with scheme urn:anvato:es1:052016 — whose 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 (default) |
Buffers the next ad into the (attached) ad player while content keeps playing — lowest gap at break start. Requires two simultaneous MediaSources. | Desktop, Android, modern Smart TVs. |
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) or LG (webOS) TV — including current models. |
auto |
Resolves at runtime via User-Agent inspection: Samsung/LG TVs → single-decoder, everything else → parallel. |
You ship one build across mixed devices. |
auto heuristic flags every Tizen (Samsung) and webOS (LG) 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 not positively identified as a TV (desktop, mobile) is treated as parallel-capable. An adapter may also report supportsParallelBuffering: false to force single-decoder under auto.adPreload: 'parallel' on a Samsung/LG 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.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>.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 DolbyAds({
// ...
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 timer ends the break at the real break boundary (notstart + full duration). - 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 DolbyAds({
// ...
tuneIn: { enabled: true, minBreakDurationSeconds: 5 },
});
SSAI mode (mode: 'ssai')
By default the SDK runs SGAI (client-scheduled) insertion. Set mode: 'ssai'
to switch to server-side ad insertion: the SDK plays a single pre-stitched
stream from the @dolby-ads/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 DolbyAds({
mode: 'ssai',
orgId: 'your-org-id',
player: new HlsJsAdapter(hls, video), // must expose videoElement (IMA binds to it)
stitcherBaseUrl: 'https://stitch.example.com',
gam: { networkCode: '23285652104' },
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({ channelId: 'your-channel-id', customAssetKey: 'asset-key' });
mode: 'ssai' requires stitcherBaseUrl and gam.networkCode; startSession requires 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 |
|---|---|---|---|
| channelId | string | ✅ | Channel ID from the Optiview Ads platform. Determines the manifest endpoint. |
| customAssetKey | string | ❌ | GAM DAI custom asset key for this channel. Required when gam is configured. |
| adTagParameters | Record<string, string> |
❌ | Per-session ad targeting parameters. Merged with GamConfig.adTagParameters; session values take precedence on key conflicts. |
GamConfig optional
| Property | Type | Required | Description |
|---|---|---|---|
| networkCode | string | ✅ | Google Ad Manager network code. |
| adTagParameters | Record<string, string> |
❌ | Org-level ad tag parameters, merged with per-session parameters. |
| streamActivityMonitorId | string | ❌ | Debug session ID for Google's Stream Activity Monitor tool. |
Example
const sdk = new DolbyAds({
orgId: '0bda787b-b10a-4e9e-a5f2-c0952d1b8a88',
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: {
networkCode: '23285652104',
adTagParameters: { ott_placement: '0' }, // org-level defaults
streamActivityMonitorId: 'my-debug-session',
},
debug: false,
});
Simplified setup with @dolby-ads/sdk
Import DolbyAds from @dolby-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 { DolbyAds, HlsJsAdapter } from '@dolby-ads/sdk';
const sdk = new DolbyAds({
orgId: '0bda787b-b10a-4e9e-a5f2-c0952d1b8a88',
player: new HlsJsAdapter(hls, video),
container: document.getElementById('container'),
// createAdAdapter omitted -> default HLS.js ad player (native fallback)
adPreload: 'auto',
});
Android (Kotlin)
DolbyAdsConfig (com.dolby.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 DolbyAds constructor (its overlayContainer FrameLayout is the Android equivalent of container).
| Property | Type | Default | Description |
|---|---|---|---|
orgId |
String |
— (required) | Organization ID from the Optiview Ads platform. |
player |
PlayerAdapter |
— (required) | Adapter wrapping your content player (e.g. ExoPlayerAdapter). |
manifestBaseUrl |
String |
Optiview default | Override the manifest service base URL. |
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). |
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 (same semantics as web). Set 0.0 to cut at the boundary. |
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(channelId, customAssetKey? = null, adTagParameters = emptyMap()) and GamConfig(networkCode, adTagParameters = emptyMap(), streamActivityMonitorId? = null) mirror the web shapes.
import com.dolby.ads.sdk.*
val sdk = DolbyAds(
config = DolbyAdsConfig(
orgId = "0bda787b-b10a-4e9e-a5f2-c0952d1b8a88",
player = ExoPlayerAdapter(contentPlayer),
adPreload = AdPreloadMode.PARALLEL,
chaining = ChainingConfig(enabled = true, maxGapSeconds = 2.0),
tuneIn = TuneInConfig(enabled = true, minBreakDurationSeconds = 5.0),
diagnostics = DiagnosticsConfig(bufferSize = 200),
gam = GamConfig(
networkCode = "23285652104",
adTagParameters = mapOf("ott_placement" to "0"), // org-level defaults
streamActivityMonitorId = "my-debug-session",
),
debug = false,
),
renderer = OverlayAdRenderer(context, overlayContainer, contentAdapter, gamConfig = gam),
manifestSource = HttpManifestSource(scope),
ticker = CoroutineSchedulerTicker(scope),
)
iOS / tvOS (Swift)
DolbyAdsConfig (DolbyAdsSDK, re-exported by DolbyAdsRuntime) 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).
| Property | Type | Default | Description |
|---|---|---|---|
orgId |
String |
— (required) | Organization ID from the Optiview Ads platform. |
player |
PlayerAdapter |
— (required) | Adapter wrapping your content player (e.g. AVPlayerAdapter). |
manifestBaseUrl |
String |
Optiview default | Override the manifest service base URL. |
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 (same semantics as web). Set 0.0 to cut at the boundary. |
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(channelId:customAssetKey:adTagParameters:) and GamConfig(networkCode:adTagParameters:streamActivityMonitorId:) mirror the web shapes.
import DolbyAdsRuntime
let gam = GamConfig(
networkCode: "23285652104",
adTagParameters: ["ott_placement": "0"], // org-level defaults
streamActivityMonitorId: "my-debug-session"
)
let sdk = DolbyAds(
config: DolbyAdsConfig(
orgId: "0bda787b-b10a-4e9e-a5f2-c0952d1b8a88",
player: AVPlayerAdapter(player: contentPlayer),
adPreload: .parallel,
chaining: ChainingConfig(enabled: true, maxGapSeconds: 2.0),
tuneIn: TuneInConfig(enabled: true, minBreakDurationSeconds: 5.0),
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).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({
channelId: 'd7803a87-465a-4e43-b12e-6f479be119a1',
customAssetKey: 'my-asset-key',
adInsertionType: 'replacement', // 'replacement' (DAR, default) | 'insertion' (DAI)
adTagParameters: { 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 (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 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, as configured. 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 pts (VOD) timebase 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.
sdk.endSession();
updateAdTagParameters(params: Record<string, string>): void
Replaces the active GAM session's ad tag parameters in real time. Useful for updating targeting (e.g. sport segment changes) without restarting the session. The new parameters are merged with the base config params.
sdk.updateAdTagParameters({ cust_params: 'sport=basketball' });
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-ads/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 DolbyAds({ ... });
// User starts watching a channel
await sdk.startSession({ channelId, customAssetKey });
hls.loadSource(channelUrl);
// User switches to another channel
sdk.endSession();
await sdk.startSession({ channelId: newChannelId, 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 / updateAdTagParameters / 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. |
updateAdTagParameters |
fun updateAdTagParameters(params: Map<String, String>) |
Replaces ad-tag params on the live GAM session. No-op (logs) without an active GAM session. |
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(
channelId = "d7803a87-465a-4e43-b12e-6f479be119a1",
customAssetKey = "my-asset-key",
adTagParameters = mapOf("cust_params" to "genre=sports"),
),
)
contentPlayer.setMediaItem(MediaItem.fromUri(channelUrl))
contentPlayer.prepare()
contentPlayer.playWhenReady = true
}
// Update targeting mid-session (no restart)
sdk.updateAdTagParameters(mapOf("cust_params" to "sport=basketball"))
// Switch channel
sdk.endSession()
scope.launch { sdk.startSession(SessionConfig(channelId = newChannelId, 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. |
updateAdTagParameters |
func updateAdTagParameters(_ params: [String: String]) |
Replaces ad-tag params on the live GAM session. |
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(
channelId: "d7803a87-465a-4e43-b12e-6f479be119a1",
customAssetKey: "my-asset-key",
adTagParameters: ["cust_params": "genre=sports"]
)
)
contentPlayer.replaceCurrentItem(with: AVPlayerItem(url: channelUrl))
contentPlayer.play()
} catch {
print("startSession failed:", error)
}
}
// Update targeting mid-session (no restart)
sdk.updateAdTagParameters(["cust_params": "sport=basketball"])
// Switch channel
sdk.endSession()
Task { try await sdk.startSession(SessionConfig(channelId: newChannelId, customAssetKey: newKey)) }
// Teardown
sdk.destroy()
Events
Subscribe via sdk.addEventListener(type, handler). All events include a type string and a timestamp (ms since epoch).
DolbyAdsEventType) 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 (below) is currently Web-only — it ships with the Web SDK's new unified sdk.muted/sdk.volume audio API; 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 |
| 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) |
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'.
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 DolbyAdsEvent {
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. - 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. 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: DolbyAdsEventType, handler: (DolbyAdsEvent) -> 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.ads.sdk.*
// Break boundaries
sdk.addEventListener(DolbyAdsEventType.ADBREAKBEGIN) { event ->
val e = event as AdBreakBeginEvent
Log.d("Ads", "Break ${e.break_.id} (${e.break_.duration}s)")
}
// Individual ads
sdk.addEventListener(DolbyAdsEventType.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(DolbyAdsEventType.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(DolbyAdsEventType.WAITING) { event ->
if ((event as WaitingEvent).source == PlaybackSource.CONTENT) showSpinner()
}
// Ad progress — drive a custom progress bar
sdk.addEventListener(DolbyAdsEventType.ADTIMEUPDATE) { event ->
val e = event as AdTimeupdateEvent
progressBar.progress = ((e.currentTime / e.duration) * 100).toInt()
}
// Unsubscribing
val handler: DolbyAdsEventListener = { event -> Log.d("Ads", "$event") }
sdk.addEventListener(DolbyAdsEventType.ADBREAKBEGIN, handler)
// Later:
sdk.removeEventListener(DolbyAdsEventType.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 DolbyAdsRuntime
// 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) = event {
print("Ad \(adIndex + 1)/\(totalAds) — asset \(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: ad tag parameter values are stripped (only hasAdTagParameters 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 DolbyAds({
// ...
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-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-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-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 | useAnvatoID3: no Anvato in-stream cue observed yet for a PTS break; the break stays pending. |
| DA-ANVATO-CUE-MATCHED | break | info | useAnvatoID3: a PTS break was matched to an Anvato cue and scheduled at the cue's media time. |
| DA-PRELOAD-FAILED | preload | warn | Ad preload failed; asset will load on demand at break start. |
| DA-BREAK-SUPPRESSED-OVERLAP | break | warn | A break was suppressed because an ad was already playing. |
| 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-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. |
DA-EVENT code (category event), so a single exported report shows diagnostics and the playback timeline together.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. |
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.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.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 DolbyAdsConfig.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 (DolbyAdsSDK):
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 DolbyAdsConfig(diagnostics: DiagnosticsConfig(bufferSize: 200)).
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
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://optiview-ads.cdn.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",
"adTagParams": { "iu": "/23285652104/dcoffey-v2-adunit" }, // object or JSON string
"preRoll": { "enabled": true, "format": "single", "delaySeconds": 0 },
"stitcherBaseUrl": "http://localhost:4600", // SSAI only
"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(DolbyAdsEventType.ADBREAKBEGIN) {
playerControls.isVisible = false
breakToast.isVisible = true
}
sdk.addEventListener(DolbyAdsEventType.ADBREAKEND) {
breakToast.isVisible = false
playerControls.isVisible = true
}
// Countdown that stays in sync with actual ad playback
sdk.addEventListener(DolbyAdsEventType.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 (full background) | Content and ad play side-by-side in equal boxes (920×517.5px at 1080p, 20px border). Companion image fills the full background behind both boxes. Animated transition in/out. |
| lshape_ad | ✅ Yes | ✅ Image (full background) | Content player is hidden. Ad video plays in a pip at the top-left quadrant (5% inset). Companion image fills the full background behind the pip. 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]) fills the full background. 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) |
Full background, 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) |
Full background, 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], full background, 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://optiview-ads.cdn.sneezysparrow.com/manifest/v1/{orgId}/channels/{channelId}
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"
}
}
]
}
}
]
}
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) | Compared against player.currentTime. |
Pre-roll breaks
A break with position: "pre" triggers relative to session start instead of the timebase-derived start value. An optional delay (seconds, default 0) controls how long content plays before the pre-roll fires.
{
"id": "preroll-1",
"start": 0,
"position": "pre",
"delay": 0,
"duration": 15,
"variant": { "format": "single", "assets": [{ "..." }] }
}
| Field | Type | Default | Description |
|---|---|---|---|
position |
"pre" |
— | Declares this break as a pre-roll. |
delay |
number (seconds) | 0 |
Content playback time before the pre-roll triggers. |
Note: Pre-rolls are only supported in SGAI mode (client-side insertion). The
startfield is still required for schema compatibility but is ignored whenpositionis set.
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) that, with one click, starts the local manifest server, creates a pre-roll channel, and plays a pre-roll in an SGAI session. Selectors choose the content player (HLS.js / Shaka / THEOplayer / Native HLS) and the ad experience (any of the five break formats — single, double-box, L-shape ad, L-shape content, overlay), and a checkbox switches between the immediate and 5-second-delayed variants. 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 aposition: "pre"break, 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.
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). |
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.
docs/dolby-ads-manifest-spec.md in the SDK repository.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 { DolbyAds, type ManifestRequest } from '@dolby-ads/core';
const sdk = new DolbyAds({
// ...
// 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 DolbyAds({
// ...
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 { DolbyAds, type BreakManifest } from '@dolby-ads/core';
const sdk = new DolbyAds({
// ...
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: 0,
position: 'pre',
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.
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 DolbyAds({
orgId: '...',
player: contentAdapter,
container: document.getElementById('container'),
playerContainer: document.getElementById('playerContainer'),
createAdAdapter: (adVideo) => buildAdAdapter(adVideo),
gam: {
networkCode: '23285652104', // your GAM network code
adTagParameters: {
ott_placement: '0', // 0 = single / full-screen
cust_params: 'genre=sports', // custom targeting
},
},
});
await sdk.startSession({
channelId: 'your-channel-id',
customAssetKey: 'your-asset-key', // per-channel key
adTagParameters: { cust_params: 'region=eu' }, // merged at session level
});
Ad tag parameters
Parameters from GamConfig.adTagParameters and SessionConfig.adTagParameters are merged before each IMA session. Session values override config values on key conflicts.
To update parameters during an active session (e.g. content segment changes):
sdk.updateAdTagParameters({ cust_params: 'genre=basketball' });
This calls IMA's StreamManager.replaceAdTagParameters() under the hood — no session restart needed.
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.).
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-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-ads/sdk → ai/skills/dolby-quickstart-demo/SKILL.md |
| Adapter skill | Bounded instructions to scaffold + validate a PlayerAdapter. |
@dolby-ads/sdk → ai/skills/dolby-adapter-integration/SKILL.md |
| Troubleshooter agent | Diagnostic-reasoning instructions for reading a report. | @dolby-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-ads/mcp). |
npx 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-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-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-ads/mcp is a Model Context Protocol
stdio server. It runs locally; diagnostics never leave your machine. Register
it with your MCP-capable client:
{
"mcpServers": {
"dolby-ads": {
"command": "npx",
"args": ["-y", "@dolby-ads/mcp"]
}
}
}
It exposes four 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 |
category, severity, summary, remediation |
explain_event_timeline |
a report or event array | the playback (DA-EVENT) sequence and ad-break lifecycle anomalies |
scaffold_quickstart |
orgId, channelId, player, contentUrl (+ optional environment, gam) |
a self-contained index.html demo plus notes and warnings |
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.
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 Dolby 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.
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-ads/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 Dolby 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-ads/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.
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}
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(cumulativeEXTINF) andwallclock(EXT-X-PROGRAM-DATE-TIME) are both 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 { DolbyAds } from '@dolby-ads/core';
import { HlsJsAdapter } from '@dolby-ads/adapter-hlsjs';
const sdk = new DolbyAds({
mode: 'ssai',
orgId: 'your-org-id',
player: new HlsJsAdapter(hls, video), // must expose a videoElement (IMA binds to it)
stitcherBaseUrl: 'https://stitch.example.com',
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.
await sdk.startSession({ channelId: 'your-channel-id', 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'requiresstitcherBaseUrlandgam.networkCode;startSessionrequirescustomAssetKey.customAssetKey/adTagParameterscome fromstartSession; org-levelgam.adTagParametersare merged with the per-session ones.- 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-ads/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-ads/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 Dolby 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 docs/dolby-ads-manifest-spec.md).
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-ads/break-manifest-server';
const app = createBreakManifestApp({
orgId: '0bda787b-b10a-4e9e-a5f2-c0952d1b8a88',
port: 4100,
});
app.listen(4100);
Installing the SDK (beta artefacts)
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/0.43.1-beta.56/. 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/0.43.1-beta.56/dolby-ads-sdk-0.43.1-beta.56.tgz hls.js
The internal @dolby-ads/* 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-ads/sdk |
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/0.43.1-beta.56/dolby-ads-sdk-0.43.1-beta.56.tgz |
@dolby-ads/core |
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/0.43.1-beta.56/dolby-ads-core-0.43.1-beta.56.tgz |
@dolby-ads/adapter-hlsjs |
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/0.43.1-beta.56/dolby-ads-adapter-hlsjs-0.43.1-beta.56.tgz |
@dolby-ads/adapter-shaka |
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/0.43.1-beta.56/dolby-ads-adapter-shaka-0.43.1-beta.56.tgz |
@dolby-ads/adapter-theoplayer |
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/0.43.1-beta.56/dolby-ads-adapter-theoplayer-0.43.1-beta.56.tgz |
@dolby-ads/adapter-test-kit |
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/0.43.1-beta.56/dolby-ads-adapter-test-kit-0.43.1-beta.56.tgz |
@dolby-ads/mcp (AI helpers) |
npm install https://ads-sdk.xagget.prudentgiraffe.com/web/develop/0.43.1-beta.56/dolby-ads-mcp-0.43.1-beta.56.tgz |
To pin a version, swap 0.43.1-beta.56 for the version you want; the browsable index at /web/develop/0.43.1-beta.56/ 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 { DolbyAds, HlsJsAdapter } from '@dolby-ads/sdk';
Android (Gradle)
The Android modules are published as a static Maven repository under /android/develop/0.43.1-beta.56/ (group com.dolby.ads). Add the repository, then the dependencies:
// settings.gradle.kts — dependencyResolutionManagement { repositories { … } }
maven { url = uri("https://ads-sdk.xagget.prudentgiraffe.com/android/develop/0.43.1-beta.56") }
google()
mavenCentral()
// app/build.gradle.kts — dependencies { … }
implementation("com.dolby.ads:dolbyads-runtime:0.43.1-beta.56") // Android runtime (Media3/ExoPlayer + IMA)
// dolbyads-sdk (orchestrator) and dolbyads-core (portable brain) resolve transitively.
# gradle.properties — REQUIRED
# dolbyads-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.ads:dolbyads-runtime:0.43.1-beta.56 |
| Player-agnostic orchestrator | com.dolby.ads:dolbyads-sdk:0.43.1-beta.56 |
| Portable brain | com.dolby.ads:dolbyads-core:0.43.1-beta.56 |
PlayerAdapter conformance kit (test) |
com.dolby.ads:adapter-test-kit:0.43.1-beta.56 |
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.ads:* modules, so adding the one maven { … } repository resolves the whole graph. Browse /android/develop/0.43.1-beta.56/.
iOS / tvOS (Swift Package Manager)
The Apple modules are published as zipped binary .xcframeworks under /ios/develop/0.43.1-beta.56/, 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 |
|---|---|---|
DolbyAdsRuntime.xcframework.zip |
AVPlayer adapter + UIKit overlay | DolbyAdsSDK + DolbyAdsCore + the Google IMA package |
DolbyAdsSDK.xcframework.zip |
Player-agnostic orchestrator | DolbyAdsCore |
DolbyAdsCore.xcframework.zip |
Portable brain | — (standalone) |
// Package.swift — full native runtime (AVPlayer + IMA)
dependencies: [
.package(url: "https://github.com/googleads/swift-package-manager-google-interactive-media-ads-ios", from: "3.18.4"),
],
targets: [
.binaryTarget(name: "DolbyAdsCore",
url: "https://ads-sdk.xagget.prudentgiraffe.com/ios/develop/0.43.1-beta.56/DolbyAdsCore.xcframework.zip",
checksum: "<see manifest.json>"),
.binaryTarget(name: "DolbyAdsSDK",
url: "https://ads-sdk.xagget.prudentgiraffe.com/ios/develop/0.43.1-beta.56/DolbyAdsSDK.xcframework.zip",
checksum: "<see manifest.json>"),
.binaryTarget(name: "DolbyAdsRuntime",
url: "https://ads-sdk.xagget.prudentgiraffe.com/ios/develop/0.43.1-beta.56/DolbyAdsRuntime.xcframework.zip",
checksum: "<see manifest.json>"),
.target(name: "App", dependencies: [
"DolbyAdsRuntime", "DolbyAdsSDK", "DolbyAdsCore",
.product(name: "GoogleInteractiveMediaAds", package: "swift-package-manager-google-interactive-media-ads-ios"),
]),
]
// Orchestrator only: DolbyAdsSDK + DolbyAdsCore. Brain only: DolbyAdsCore.
Each release's exact url + SHA-256 checksum for every product is in manifest.json (mirrored as a copy-paste snippet at /ios/develop/0.43.1-beta.56/) — paste each checksum into its .binaryTarget. Slices: iOS device + iOS simulator (tvOS slices are tracked in PLAYG-76).
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-ads/stitcher, @dolby-ads/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 repository's docs/dolby-ads-manifest-spec.md.
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 position: "pause" and the pause
break format (the legacy pause_image value is still accepted and resolves
identically).
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.
Asset shape (static video)
{
"id": "pause-1",
"start": 0,
"position": "pause",
"duration": 0,
"variant": {
"format": "pause",
"assets": [
{
"id": "pause-static-video",
"type": "static",
"mediaType": "video",
"uri": "https://cdn.example.com/ads/pause.mp4",
"interaction": { "clickThrough": "https://example.com/landing" }
}
]
}
}
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. delay (seconds) is how long the content must
stay paused before the ad appears (default 0).
Try it
The Pause ads demo page creates a channel with a pause ad for the selected
source — one of four: image/video × static/VAST — starts an SGAI session, and
loads content. Press play, then pause the video — the pause image or video
appears with a resume button; resuming dismisses it. 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-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 Dolby 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.js. - 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 DolbyAds({
// …
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 DolbyAds({
// …
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 a
position: 'pause' / format: 'pause' break. 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), which maps to the
break's delay: for the pre-roll it is the seconds of content playback before the
pre-roll fires; for the pause ad it is the seconds the content must stay paused
before the pause ad appears. 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 dolby-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-ads/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 + VAST pod duration. A mid-roll ad-format selector (single /
double / L-Shape — VAST is linear-only) fills every detected break with a VAST
ad. The VAST request carries
pmndandpmxdquery parameters (the min/max ad-pod duration in milliseconds), set to the break's duration byvastTagForBreakDuration()inpackages/demo/src/customer-demo/vast-pod.ts. - Configurable VAST tag, 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. The page exposes: an editable VAST tag URL (default is Google's public sample tag); 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. Pre-roll usesposition: 'pre', pause usesposition: 'pause'+format: 'pause'. Each of the pre-roll and pause controls has a delay (seconds) input — the pre-roll delay is playback time before the pre-roll fires; the pause 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.jsstages 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. - VAST pod params are opt-in. Appending
pmnd/pmxd(ad-pod duration) turns a request into an ad-pod request; non-pod tags (like Google's single-ad sample tag, the page default) answer that with no ads.assembleBallysManifestonly adds them whenpodDuration: true, so the default tag fills. - 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.
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.js. - Add unit tests for any pure logic (manifest building, parameter helpers) and
update this page plus the root
CHANGELOG.md.
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.js) 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. 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, including the one playing. |
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-ads/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 DolbyAds({
orgId,
channelId,
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({ channelId, orgId });
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).
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.
[Unreleased]
Fixed
- A wallclock session no longer schedules breaks against the system clock while it is still waiting for the stream's Program Date Time (PLAYG-360). A session is normally started BEFORE the content player has loaded its source — the web and iOS integrations both
await startSession()and only then load the stream — so the scheduler's first ticks readprogramDateTime === nullon a stream that carries PDT perfectly well. The scheduler treated that first null as proof the stream had none: it emitted the one-shotDA-PDT-MISSING(so a healthy MediaKind origin warned on every session, on every web adapter and on iOS) and, worse, fell back to the system clock for the rest of that startup window. The clock sits AHEAD of the live playhead by the stream's latency — ~14s on the observed origin — so a break whose start fell inside that gap looked already-started and fired immediately as a bogus tune-in, and the ~14s backward step when PDT finally arrived was then read as a DVR seek-back, which re-armed the break and fired it again. That is the "adbreakbegin1.1s afterstartSession, then again 2s later" behaviour previously recorded as tune-in coverage. Now: while no PDT is available the tick makes no scheduling decision at all — waiting is the only honest answer, since until the stream says where the playhead is, the SDK does not know — and only afterpdtGraceSeconds(new, default5) with still no PDT does it warn once and fall back to the clock, so genuinely PDT-less streams keep working exactly as before. A stream that has already produced a PDT and then hits a transient playlist gap never substitutes the clock and never warns; it waits for the next sample. Pre-rolls are session-relative and keep firing immediately, independent of PDT. The wait ends as soon as the stream has had the chance to answer, not after a fixed delay: once content has been playing for ~1s and still exposes no PDT, the SDK concludes immediately — playing content is direct evidence about the stream, and the local device run showed that waiting out the full window instead costs monetisation, because a join-in-progress break loses ~5s of its remaining duration and can drop below the tune-in minimum (hls.js on Chrome and native on Safari both skipped a tune-in that used to play).pdtGraceSecondsis therefore a backstop for the case where playback never starts at all (blocked autoplay, stalled load). Fixed identically in all three cores (TS, Kotlin, Swift) and pinned by seven new conformance fixtures. - The install docs pointed at the retired artefact endpoint. The demo site's Install / Distribution page and the README still directed consumers to
ads-sdk.xnappet.live/artifacts/…— the hand-deployed, single-version endpoint that CI publishing replaced. All install commands, browsable-index and manifest links now use the artefact host's per-version prefixes (https://ads-sdk.xagget.prudentgiraffe.com/<platform>/main/<version>/…), with the version stamped into the docs at build time as before. The links are also channel-aware: CI stampsdevelop/0.43.1-beta.56from the deploying pipeline (DOLBY_ADS_CHANNEL/DOLBY_ADS_VERSION), so the develop preview site's install docs point at the exactdevelop/<beta>artefacts that same pipeline published, while release builds keepmain/<version>.
Added
The development workflow now targets
develop, notmain.scripts/bb.sh pr-createdefaulted its destination tomainandscripts/new-worktree.shbranched offorigin/main, withAGENTS.md,CONTRIBUTING.md,RELEASING.md,docs/worktrees.md,docs/develop-workflow-details.md,.windsurf/workflows/develop.mdand the Devin playbook all describingmainas the PR target — while the actual branch model isdevelopintegrates,mainreleases (a version cut advancesmainand is back-merged intodevelop). Following the documented flow therefore put unreleased work on the release branch and leftdevelopwithout it, which is exactly what happened to PLAYG-360. The scripts now default todevelop/origin/developand every doc states the model explicitly.pdtGraceSecondsconfiguration (web, Android, iOS). Backstop for how long to wait for the stream'sEXT-X-PROGRAM-DATE-TIMEon awallclock-timebase session before concluding the stream carries none, used only while the content player has not started playing. Default5; set0for the previous immediate clock fallback, or raise it when an adapter is known to surface PDT unusually late while already playing. No effect onptsmanifests. TheDA-PDT-MISSINGdiagnostic now carriescontext.graceSecondsandcontext.playbackStarted, which tells apart "this stream has no PDT" (playing, the common case) from "nothing ever played" (the backstop).The conformance harness compares diagnostics, not just events. A timeline fixture's golden now carries a
diagnosticsarray ({ code, breakId? }, in emission order, omitted when empty), so a diagnostic's presence and absence is enforced across TS/Kotlin/Swift — message wording stays out of the golden since it differs harmlessly per language. Timeline steps also acceptprogramDateTime: nullto model a stream that has not exposed a PDT yet, andpdtGraceSeconds; a step combiningcurrentTime > 0withprogramDateTime: nullmodels PLAYING content that carries no PDT. This closed the ticket's open question: the Kotlin core was never divergent.
[0.43.1] - 2026-08-03
Fixed
- Web: a GAM pod holding five or six ads was reported as one ad. A pod is a single
vendorasset in the break manifest, so the SDK's ad sequencer — which plans oneadbegin/adendpair per manifest asset — could only ever announcead 1/1, while Google filled the pod with several creatives. The give-away was in the events themselves: 100-148s pods emitted five or six complete quartile cycles between oneadbeginand oneadend, and one ad cannot pass its own midpoint six times. IMA knows the real composition and reports it per ad (getAdPodInfo()), but all three cores discarded that payload and registered argument-less listeners. The SDK now expands the pod at runtime through the portableGamPodAdTracker: oneadbegin/adendpair per real ad, with truthfuladIndex/totalAds, a per-adassetderived from the manifest pod asset as<podAssetId>-ad-<n>, and IMA'sadId/creativeIdon both events.adbreakbegin/adbreakendstill bracket the pod as a whole. Pairs stay balanced when IMA drops a completion, when the pod ends mid-ad, and when the break-cut timer fires. Because the composition is only knowable once IMA reports it, the firstadbeginwaits up to 3s for that report instead of announcing a value it would have to contradict; if nothing arrives — an unfilled pod, or an IMA session receiving no timed metadata — the pod is reported as a single ad exactly as before, so no context regresses. Android and iOS still report a pod as one ad; their ports are tracked as a conformance parity gap (conformance/PENDING-PARITY.md). Separately, iOSavplayeremits no quartiles at all for a pod, which is a different defect in how its IMA session is fed timed metadata and is tracked on its own.adtimeupdatedeliberately stays pod-level: a pod is one stitched stream, so itscurrentTime/durationdescribe the whole pod rather than the creative on screen, and itsassetremains the manifest pod asset. - Web: quartile events could not be attributed to an ad inside a GAM pod.
adfirstquartile/admidpoint/adthirdquartilecarried only the pod asset, so six cycles under one reported ad were indistinguishable from each other — threeadmidpointevents for what the API called one ad have no valid interpretation for an analytics or tracking consumer. All three quartile events now carry the optionaladIndex/totalAdsof the ad they belong to, and the pod ad's own derivedasset. They remain absent where the SDK has no per-ad breakdown, so existing consumers are unaffected. - Published artefacts now reference the host they are published to. The web tarballs' internal
@dolby-ads/*dependency URLs and the web/iOS manifests'baseUrl/urlfields (and the Android index page's Maven snippet) were hard-coded to the demo site, which only ever hosts one hand-deployed version — so installing an S3-published tarball 404'd halfway through its dependency graph. CI now derives the base URLs from the exact per-branch, per-version S3 prefix being published to (served atads-sdk.xagget.prudentgiraffe.com; override with theARTIFACTS_PUBLIC_BASEproject env var), making every artefact self-consistent without consumer-side overrides.
[0.43.0] - 2026-08-02
Added
iOS: the SDK drives system picture-in-picture for the content player.
AdRenderer.setPictureInPicture()/isPictureInPicture()locate the integrator'sAVPlayerLayerby the asset the content player reports and open the system window on it, so the ad flow — which decides break format from whether that window is open — owns the transition without the SDK having to become a required part of the content path. The audio session is put into.playbackon the way, sinceAVPictureInPictureControllerwill not start without one.iOS: a viewer who opens picture-in-picture DURING a break gets the same treatment as one who was already in it. Format was decided once, at break start, which left the two-box or L-shape geometry exactly as the break had drawn it, content playing under an overlay, and the ad still on the SDK's own layer — which the window does not present, so the viewer watched content while an ad they could not see played behind it. The rest of the break now adopts
singleSEMANTICS: content pauses as a fullscreen ad requires, the companion and split layout go, and the running ad moves onto the content player at the point it had reached rather than restarting.lshape_contentis the exception it has to be — it carries no fullscreen ad to promote, so the backdrop is dropped and the window keeps showing content. Each switch is recorded byDA-PIP-FORMAT-OVERRIDEwithmidBreak: true.DolbyAds.getPresentationState()on iOS, completing the parity with web and Android. It answers three questions that are easy to conflate and matter most when they disagree: whether the window is open, which media owns the surface (showing), and HOW the ad reaches the screen (insertion). An ad can be playing while the window still presents content — the viewer seeing content and not the ad — andinsertionis the only thing that distinguishes that from a healthy break. It is read from the renderer, which knows which surface it handed the ad to, rather than inferred from the window being open.The SDK plays the ad itself on the shared-element path, instead of handing it to the customer's engine. Playing an ad through the content element used to mean
contentPlayer.load(adUri), which left the outcome to that engine's swap semantics — and they differ in a way the viewer sees. hls.js swaps the manifest inside the live MediaSource and the element is never detached; Shaka'sload()unloads first, and on Safari that detach destroys the picture-in-picture window the instant the break starts, so the ad plays out on a page nobody is looking at. The SDK now drives the swap through its own ad adapter pointed at the content element (createAdAdapter(container, video)— a factory may honour the supplied element or ignore it, in which case the previous path still runs). Measured on macOS Safari with Shaka: the window survives the whole ad where it previously closed within five seconds ofadbreakbegin.PlayerAdapter.releaseMediaElement(), an optional hook asking the content engine to stop driving its<video>for the length of a shared-element ad. Two engines on one element fight over the MediaSource: measured on Safari with Shaka attached throughout, roughly half of breaks either aborted with "The operation was aborted." or looped on the ad's first segment forever. The engine keeps its instance and configuration and is handed the element back at break end. Implemented for Shaka (detach()) and THEOplayer (clearingsource); engines that swap in place need not implement it.The SDK lost track of the content element when the player swapped it.
setup*Tracking()andteardown*Tracking()each resolvedcontentPlayer.videoElementfresh, so an engine that replaces its<video>on a source change — THEOplayer does, and the SDK causes that itself every time it lends the element to an ad and hands it back — left the listeners on the dead node while teardown unbound from the new one. The SDK then never sawenterpictureinpictureorwebkitpresentationmodechangedagain: breaks kept their declared layout and a mid-break entry was never adopted, silently, for the rest of the session. Listeners are now bound to a tracked element, re-bound on any swap with the presentation state re-read from the new node, and the swap is reported asDA-CONTENT-ELEMENT-SWAPPED.releaseMediaElementis a declared adapter capability.@dolby-ads/adapter-test-kit's conformance suite now has areleaseMediaElementcapability flag and a check that releasing does not tear the adapter down — the SDK hands the element back by callingload()on the same instance, so an adapter that destroys its player there cannot restore content at break end. Declared by the Shaka adapter; third-party adapters that own their element should declare it too.
Fixed
- iOS: the picture-in-picture format override never reached the public events. Every event was built from the MANIFEST's format (
resolveBreakFormat), so adoublebreak played correctly as a single ad in the window — the renderer had already overridden its own layout — whileadbreakbeginannouncedformat=doubleto every listener.DolbyAdsCore.effectiveBreakFormat, documented as "what the public events report", had no callers. All fourteen event and status payloads now report the effective format,DA-PIP-FORMAT-OVERRIDErecords the declared → effective mapping per break, and the flag follows the window actually opening or closing — never the request — so a refused transition cannot make a break claim a layout the viewer is not seeing. - iOS: picture-in-picture never opened, and said nothing about why.
startPictureInPicture()was issued from inside AVKit's ownisPictureInPicturePossibleKVO notification underMainActor.assumeIsolated, which asserts an isolation KVO does not guarantee rather than moving the work to the main thread. AVKit drops a start made that way: no window, no error, no delegate callback, andisPictureInPicturePossiblestill true afterwards. On an iPad whose Safari does picture-in-picture perfectly, every documented precondition measured true while the SDK's window never appeared. The request now runs on a clean main-queue turn, is retried once, and a refusal surviving both attempts raisesDA-PIP-TRANSFER-FAILEDrather than another silence. - Web: the SDK could believe it was presenting a picture-in-picture window that had already closed — or miss one that was open. Four places answered "are we in picture-in-picture" and had drifted apart, so fixing one left the others reporting the old answer. They now share a single check, which asks BOTH APIs: on Safari either may be the one telling the truth and they disagree. Measured on macOS Safari with a window demonstrably open,
webkitPresentationModereadinlinethroughout whiledocument.pictureInPictureElementcorrectly pointed at the element — so trusting either alone is wrong. Surfaced by exercising Shaka and THEOplayer on Safari for the first time, where it cost five of six mid-break scenarios while hls.js happened to escape.
[0.42.0] - 2026-07-31
Added
Picture-in-picture forces the
singlelayout, on web and Android (PLAYG-PIP). While the content player is in picture-in-picture the browser draws nothing but the PiP'd video, so a two-box, L-shape or overlay break cannot be read — every break now plays assinglefor as long as the window is open. The override reaches the BEHAVIOUR, not just the geometry: content pauses as a fullscreen ad requires, and on live the viewer's distance from the live edge is restored on resume, exactly as a declaredsinglebreak would. It applies whether the viewer was already in picture-in-picture when the break began or popped into it mid-ad, and it is undone when the window closes.DA-PIP-FORMAT-OVERRIDEreports each override with the declared and effective formats.DolbyAds.setPictureInPicture(),isPictureInPicture()andgetPresentationState(). An integrator no longer has to infer presentation from the DOM.getPresentationState()answers three separate questions that used to be conflated: whether the window is open, which media is playing (showing), which element owns the WINDOW (windowOwner), and HOW the ad reaches the screen (insertion). The last two differ precisely when a hand-over is refused — the ad plays while the viewer still sees content — which nothing could previously observe. Android exposes the same surface plussetPictureInPicture()for the activity's own PiP transitions.Shared-element insertion now works over MSE. Playing an ad through the content element — the only way to put an ad in the picture-in-picture window on Safari, and the mechanism iOS needs for its
AVPlayerLayer— was previously refused whenever the element carried a MediaSource, which is every hls.js and Shaka stream. Reloadability is now asked of the ADAPTER rather than read off the element, via a new optionalPlayerAdapter.sourceUrlreporting the manifest the engine is playing (hls.url, Shaka'sgetAssetUri(), the element's ownsrcfor native). Content is restored from that source; on live, the viewer's distance from the live edge is re-applied once the reloaded stream has a seekable range, because a reloaded live manifest starts at the edge and the old position refers to a timeline that no longer exists.DA-PIP-TRANSFER-FAILED. Moving the picture-in-picture window to the ad element is verified rather than assumed. Safari accepts the request and silently declines it — measured, with a three-second wait — so the SDK now reports the refusal and plays the ad through the content element instead.E2E runner: deferred device-allocation retry for same-host contention (PLAYG-342). Selector-mode platforms run fully in parallel (PLAYG-329), so a long-running platform can hold the shared lab host while a sibling's allocation window starves — previously indistinguishable from a genuinely offline installer, so the starved platform's cells were all marked
erroredafter the normaldeviceRetriescycle even though a standalone re-run passed.runMatrix()now tracks which platforms in the run are still active; once a platform exhausts its normal allocation retries, it backs off with exponential backoff (capped, up tomaxDeferredRetriesrounds, default 10) and retries a fresh allocation cycle as long as a sibling platform is still active — stopping to report a harderroredimmediately once either no sibling remains (a real outage, not contention) or the round cap is hit.packages/test-app/src/runner/orchestrator.ts; docs updated (docs/e2e-test-plan.md,docs/e2e-device-runs.md); unit tests added for both the deferral-then-success and the stop-deferring-when-siblings-finish paths. Internal tooling — no SDK/core/brain change.
Fixed
- A shared-element break reported no ad progress at all.
onAdTimeUpdate— and the static-asset quartiles derived from it — lived on the ad player'stimeupdate, but on the shared-element path there is no ad player: the ad plays through the content element, where only anendedhandler was ever attached. An ad could play perfectly while the application was told nothing andadbreakstatus.breakRemainingSecnever moved. This is the shipping iPhone path, not only picture-in-picture on Safari where it was noticed. - A break could fire itself again immediately after ending. Restoring content after a shared-element break reloads the stream, which re-arms the schedule, and the playhead visits the head of the new stream on its way back — which the rewind detector read as the viewer seeking to before the break. Measured:
adbreakend, then a secondadbreakbegin265ms later. The reload is now bracketed, because for its duration there is no meaningful previous position to compare against. - The break's content gate froze an ad that had taken over the content element. The gate re-pauses that element on every
playingevent, which is right while it carries content and fatal once it carries the ad: the ad started, the gate paused it, and the window held a still frame for the rest of the break. - Android: an ad that never STARTS now raises
aderrorinstead of running the break out in silence. The renderer waited forSTATE_READYor an error; a stalled load on a poor network gives neither, so the break played to its cut timer with an empty ad box and emitted noadbegin, noaderrorand no diagnostic — a trace indistinguishable from a healthy break. The wait for playback to begin is now bounded, matching the web SDK's first-frame budget.
[0.41.1] - 2026-07-30
Fixed
- Shaka never delivered Anvato break cues to
useAnvatoID3— ID3GEOBframes left undecoded, and cues observed too late (PLAYG-349). On a live HLS stream whose MPEG-TS segments carry Anvato ID3GEOBmarkers, every stream-signaled break stayedDA-ANVATO-CUE-PENDINGforever on Shaka, while hls.js and native Safari played the same breaks fine. Two adapter-side defects, both confirmed againstshaka-player@4.16.36: (1)GEOBis never pre-parsed by Shaka.ShakaAdapter.toCue()assumed Shaka hands over a decoded frame (description: 'Anvatos', MIMEapplication/json, object data) the way hls.js, THEOplayer and WebKit do, butshaka.util.Id3Utilsonly decodesAPIC/TXXX/WXXX/PRIV/T*/W*— aGEOBframe falls through its "Unrecognized ID3 frame type" branch and arrives with an empty description, a null MIME type, and the entire undecoded frame body as data, soisAnvatoCue()rejected every marker. (2) Cues arrived at the playhead, not ahead of it. The adapter listened only to Shaka'smetadataevent, which is dispatched from aRegionObserver'enter'handler — i.e. when the playhead reaches the cue. An Anvato cue's media time IS the break start, so the cue was learned exactly when the break already needed to be armed and preloaded.ShakaAdapternow decodes the raw GEOB body itself (via the shared parser) and additionally subscribes to Shaka's parse-timemetadataaddedevent, de-duplicating per marker so IMA never receives a doubled tracking cue — the same remedy as the Safaritrack.cuessweep in 0.40.2. Players that DO pre-parse GEOB are unaffected. Regression-tested by running real Anvato ID3 tags through Shaka's ownId3UtilsinShakaAdapter.tsId3.test.ts. Known limitation: Shaka exposes no parse-time event for DASHemsg, so Anvato-over-DASH on Shaka still resolves its cue only at the playhead (PLAYG-351). @dolby-ads/adapter-hlsjsno longer owns the ID3GEOBparser. It moved to the new player-agnostic@dolby-ads/core/id3subpath so the Shaka adapter can share it (an adapter must never depend on another adapter), gainingparseId3GeobBody()for players that surface a bare frame body.@dolby-ads/adapter-hlsjsre-exportsparseId3GeobFrames,decodeUtf8andId3GeobFrameunchanged, so no consumer import breaks.
[0.41.0] - 2026-07-30
Fixed
- iOS/AVPlayer never delivered in-band ID3 (Anvato) cues when the integrator loaded content directly —
useAnvatoID3breaks stayed pending forever.AVPlayerAdapterattached itsAVPlayerItemMetadataOutput— the only source of.timedmetadataevents — solely insideAVPlayerAdapter.load(_:). But the documented integration pattern is that the integrator owns content loading and sets the item viaplayer.replaceCurrentItem(...)directly (the SDK's ownDolbyAdsDemodoes exactly that, as does the ads-sdk-testing iOS agent); on that path the output was never attached, AVFoundation delivered zero timed-metadata groups, and every Anvato-signaled (timebase: "pts") break stayedDA-ANVATO-CUE-PENDING— the break window passed with noadbreakbegin. Observed live on the NFL Channel (iPhone, SDK 0.39.0): a manifest break visible to the device for 6+ minutes never matched a cue. The adapter now attaches a metadata output to whatever item is current, driven by aplayer.currentItemKVO observation (.initialcovers an item set before the adapter existed;.newcovers both the integrator'sreplaceCurrentItemand the SDK'sload(_:)), idempotent per item soload(_:)and the observer never double-attach, torn down indestroy(). This brings AVPlayer to parity with Kotlin'sExoPlayerAdapter(which listens on the player) and the web adapters — cues now flow no matter who loads the content. The demo needs no change; its existingreplaceCurrentItemflow is what the fix makes work. Regression-tested inAVPlayerAdapterTests(integrator-loaded item, item present before init, no double-attach withload(_:), item replacement); live coverage is the ads-sdk-testingNFL-CH-AVPLAYERsuite. Sibling of the SafariNativeVideoAdaptercue fix in 0.40.2 — no portable-brain change, so no conformance fixture applies.
Added
DA-BREAK-TRANSITIONsub-phase attribution: where a slow transition actually spends its time (PLAYG-344). The diagnostic previously reported one opaquedurationMsper transition — enough to prove the QN86D's 2574msinto-breakcost is unacceptable, but not to target it.BreakTransitionTimergainsphase(name), recording ordered checkpoints ({ name, atMs, sinceLastMs }) onto the pending measurement, surfaced ascontext.phases.AdPlayerControllerreportsuri-resolved,load-start/load-resolved(orload-skipped-preloadedwhen the break was already preloaded), andplay-called, which splits the window into uri resolve / attach+manifest / first-frame decode without any player-specific code in core — the reporter is injected viasetTransitionPhaseReporter(), mirroring the existingsetChainResolver()seam. Collected only when the SDK is constructed withdebug: true, and thephaseskey is omitted entirely otherwise, so the production report shape is unchanged. Phases live on the pending measurement, so a superseded transition (a break cut before its first ad rendered) drops its phases with it rather than mixing them into the next. First step of the Smart-TV transition-cost epic (PLAYG-331).
[0.40.2] - 2026-07-30
Fixed
- Safari/WebKit never delivered Anvato break cues to
useAnvatoID3— zero-duration ID3DataCues skipped bycuechange.NativeVideoAdaptersurfaced timed metadata only viacuechange/activeCues, but WebKit does not reliably firecuechangefor (near-)zero-duration cues — exactly the shape of Anvato break/beacon markers (observed live on NFL Network:endTime − startTime≈ 10 µs, present intrack.cuesbut never inactiveCues). Result: on Safari native HLS,DA-ANVATO-CUE-PENDINGbreaks stayed pending forever and stream-signaled ad breaks were missed. The adapter now ALSO sweepstrack.cuesontimeupdate(per-cue deduplicated against thecuechangepath, cleaned up on destroy), which additionally delivers cues at parse time — ahead of the playhead — matching the hls.js adapter's timing. Regression-tested with fixture tracks inNativeVideoAdapter.test.ts; live coverage is the existingid3-ptsnative/mac-safari cell and the ads-sdk-testingNFL-*-NATIVEsuites. - E2E test-app: GAM DAI pods never played on THEOplayer —
First frame timeouton every GAM preroll cell (PLAYG-341). The test-app pointed THEOplayer'slibraryLocationat the jsdelivr CDN. THEO's TS transmuxer loads a worker + helperiframe.htmlfrom that location, and a cross-origin location stalls TS/HLS playback silently: segments download but are never transmuxed/appended,bufferedstays empty, noerrorfires — so the first-frame gate (held-immediate fullscreen pre-rolls) timed out on all three swept platforms, andneg-gam-failuremis-ran the same lifecycle. GAM DAI pods are the only TS media the ad player ever receives (lab content is fMP4/CMAF), which is why only GAM cells failed; GAM midrolls "passed" without the gate's frame assertion. Fixed exactly like the demo (packages/demo/vite.config.jsdocuments the same constraint):packages/test-app/vite.config.tsnow stages the installed THEOplayer build intopublic/theoplayer(gitignored, always version-matched per PLAYG-220) andTHEO_LIBRARY_LOCATIONresolves page-relative (./theoplayer/, compatible with the Xagget store's base path). Regression-tested inadapters.test.ts; verified locally with a full green GAM-preroll lifecycle. Not an SDK defect — no SDK code changed.
[0.40.1] - 2026-07-30
Fixed
- Anvato cue-scheduled breaks (
useAnvatoID3) replayed in an endless loop on live players with window-relative positions (all cores). A completed break was retained for the DVR seek-back re-arm, but on a live stream a Media3 player's reported position steps BACKWARD every time the sliding live window re-anchors — indistinguishable from a user rewind at the position level — so the scheduler re-armed and re-fired the break just watched, loopingadbreakend → ~5s content → adbreakbeginforever (observed on-device on the NFL Channel live stream). Cue-scheduled breaks are now one-shot: when Anvato signaling is active they are never retained for rewind re-arm (their start is the media time of a live in-stream moment; seeking back into a live SGAI stream shows the stream's own slate, never an SDK re-splice, so nothing is lost). Fixed in lockstep in the TS reference, Kotlin, and Swift cores; conformance-locked by the newpts-anvato-no-rearmfixture; unit-tested in all three (BreakScheduler.anvato.test.ts,BreakSchedulerAnvatoTest.kt,BreakSchedulerAnvatoTests.swift). Non-Anvato DVR re-arm behaviour (PLAYG-241) is unchanged. - Android: content never resumed after a pausing break on a live stream with a pts-timebase channel. The renderer's live-latency restore (
OverlayAdRenderer.restoreLiveLatency) was gated on the CHANNEL'stimebase: "wallclock", but liveness is a property of the CONTENT: a pts channel can sit on a live stream too (the NFL Channel/NetworkuseAnvatoID3case, where pts breaks are matched to in-stream Anvato cues). There a fullscreen break pinned the playhead while the stream's short sliding window moved on; on resume the position had fallen OUT of the live window and ExoPlayer never recovered — observed on-device as position clamped to0,playingfired but content never advanced after a 120s break (hls.js and AVPlayer self-recover to the live window; ExoPlayer does not). The resume path now restores the viewer's live latency whenever the content is live regardless of timebase, driven by the new optionalPlayerAdapter.isLivecapability hint (Kotlin), whichExoPlayerAdaptermaps from Media3'sisCurrentMediaItemDynamic(ExoPlayer'sdurationis the finite window duration on live, so it cannot signal liveness);null/unknown keeps the historical VOD behaviour. Found by the ads-sdk-testing NFL Channel media3 e2e (NFL-CH-MEDIA3) postroll playout check; regression-tested inOverlayAdRendererLiveLatencyTest(pts+live restores the pre-break distance from live; VOD DAR still never seeks) andExoPlayerAdapterTest(no timeline →isLiveunknown).
[0.40.0] - 2026-07-30
Fixed
adPreload: 'auto'deadlocked playback on modern Smart TVs (PLAYG-319). Theautoheuristic (isSingleDecoderUserAgent,packages/core/src/services/detectPreloadMode.ts) gatedsingle-decoderon TV firmware version (Tizen ≤3 / webOS ≤3), so a current Samsung/LG panel resolved toparalleland the SDK attached a second MediaSource to buffer the next ad while content played. Measured on a 2024 Samsung QN86D (Tizen 8), that wedges content decode outright: the screen stays black, no ad-break events fire at all, and the session never recovers — not a slow transition, a deadlock. Version is therefore not a usable signal for decoder capacity, soautonow resolves any Tizen or webOS UA tosingle-decoder. Over-matching is cheap and bounded:single-decoderstill warms the HTTP cache and does a detached manifest/fragment prefetch, so a TV that could have buffered in parallel loses only the pre-attached buffer. Non-TV UAs (desktop, mobile) are unchanged, and an explicitadPreload: 'parallel'is still honoured on TVs (documented foot-gun). Verified on the QN86D:auto→single-decodercompletes the break and resumes content, explicitsingle-decoderbehaves identically, forcedparallelstill deadlocks. Mirrored in the Kotlin (android/dolbyads-core/.../ModeResolution.kt) and Swift (ios/DolbyAdsCore/.../ModeResolution.swift) cores and locked by themodes-tizen8conformance fixture.
Added
DA-BREAK-TRANSITIONdiagnostic: measured cost of every playback transition around a break (PLAYG-319). NewBreakTransitionTimer(packages/core/src/services/BreakTransitionTimer.ts) times the gaps where the viewer sees neither the outgoing nor the incoming media and reports each one once, withcontext.transition(into-break— break triggered → the break's first ad renders;ad-to-ad— one ad ends → the next 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),context.durationMs,context.breakId, and the resolvedcontext.adPreload/adInsertionso numbers are comparable across devices and configurations. Motivated by PLAYG-319: transition cost was previously invisible in the field, so a Smart-TV preload deadlock could only be found by staring at a black screen. A transition that never completes is dropped rather than reported with a misleading duration. The code is added to the shared taxonomy (taxonomy/diagnostic-codes.json, so all three cores carry it), with MCP remediation guidance and generated AI reference/knowledge-base docs. Measured baseline on a 2024 Samsung QN86D (Tizen 8,single-decoder, HLS.js): into-break 2574ms, out-of-break 646ms.
Documentation
- Documented how to run E2E on a Samsung Tizen TV, first time right (PLAYG-319).
docs/e2e-device-runs.mdgains a "Tizen TVs (Samsung)" section: the Xagget kit's skill 05 covers onlyweb/android/ios, and the one rule that breaks every first attempt was written down nowhere —appUrlmust be the Xagget Tizen skeleton.wgt(permanent builtin atartifacts/builtin/tizen/xagget-skeleton-samsung.wgt), not the web bundle, which belongs intarget.launchParams.bundleUrl; pointingappUrlatindex.htmlmakes the installer runtizen installon the HTML and fail withFailed to install Tizen application.. The section adds a copy-pastablerequest_devicepayload verified on the office QN86D, the fixedpackageId(ABCDEF1234.XaggetSkeleton), thetargetIdvshardwareIddistinction,mode: redirectvsinject, how to probe the index-less artifact store (HEAD→405), theoffice-win-ctv/office-mac-tvlabsregistry rows, the current TV Labs outage (tvlabs POST /session returned HTTP 502), and the fact that Xagget offers no screenshot/video-capture tool. Docs-only.
[0.39.0] - 2026-07-30
Added
- Anvato in-stream break signaling (
useAnvatoID3) — NFL Channel / NFL Network support. New org-levelDolbyAdsConfig.useAnvatoID3flag (defaultfalse, SGAI only): the SDK tracks Anvato timed-metadata cues from the content player — HLS ID3GEOBframes with descriptionAnvatos(MIMEapplication/json) and DASH emsg events with schemeurn:anvato:es1:052016, payloadtype=cue&pts=<seconds>— and resolves eachtimebase: "pts"break's start by matchingbreak.startagainst an observed cue'spts(±5 ms tolerance, closest-cue-wins with a deterministic lower-PTS tie-break), scheduling the break at the cue's media time instead of the raw manifest PTS. A break whose cue has not been observed stays pending (new one-shotDA-ANVATO-CUE-PENDINGdiagnostic;DA-ANVATO-CUE-MATCHEDon first resolution); a cue learned only after its media time has passed tune-in-triggers under the normal tune-in rules. Implemented in the portable brain (MetadataCueTracker+BreakScheduler) with full TS/Kotlin/Swift lockstep, conformance-locked by the newpts-anvato-cue-match/pts-anvato-no-cue/pts-anvato-late-cuefixtures.TimedMetadataCuegained optionaldescription/mimeTypenormalization fields, surfaced by the hls.js (new ID3GEOBdecoder), Shaka, THEOplayer, Media3, and AVPlayer adapters. Demo: "Anvato ID3 break signaling" toggle on the player page. E2E: newid3-ptsfeature (scenarioid3-pts-static-single-vod-dar+injectTimedMetadatadevice command).
Fixed
- E2E test-app: THEOplayer
First frame timeouton held-immediate fullscreen pre-rolls (PLAYG-330). The test-app created its THEOplayerChromelessPlayerinstances unmuted (the library default), while every other adapter path plays through an explicitly muted<video>. On a fresh browser profile (lab devices, no Media Engagement Index) Chrome silently blocks an unmuted, gesture-lessplay()— the ad player never firesplaying, so the SDK's first-frame gate (active only for held-immediatesingle/lshape_adpre-rolls, the exact failing subset of the PLAYG-239 sweep) timed out withDA-AD-PLAYBACK-ERROR: First frame timeout.mutedAutoplay: 'all'does not cover manualplay()calls. Both THEO instances are now created withmuted = trueinpackages/test-app/src/adapters.ts(autoplay-policy parity withappendAdVideo), regression-tested inadapters.test.ts, and verified green on the previously failing lab cells. Not an SDK defect — no SDK code changed.
[0.38.0] - 2026-07-29
Added
- Kotlin:
skipAd()/clickAd()+ theadclickevent — skip/click parity with iOS and web. The AndroidDolbyAdsfacade had no viewer skip/click surface at all (no methods, noadclickevent), which is why the Android native test agent leftskipAd/clickAdunregistered and the e2ecatalog-controlsG3/G6 scenarios failed on the tablet while passing on iOS. Ported 1:1 from the iOS implementation:DolbyAds.skipAd()honors the manifest skip policy (controls.skipOffsetdeclared AND at least that many playback-gated seconds elapsed — the samecurrentBreakElapsedSec()the countdown uses) and cuts the break through the renderer's normal break-cut path so the sequencer still emits the balancedadend+adbreakend;DolbyAds.clickAd()emits the newAdClickEventcarrying the asset's declaredinteraction.clickThroughURL (the SDK deliberately does not open it), and a viewer tap on the runtime renderer's ad surfaces routes into the same path via the newAdRenderer.setAdClickHandlerseam. Supporting model work: the Kotlin coreAssetgainedinteraction.clickThrough(parsed byManifestService, mirroring Swift'stoInteraction), and theDA-AD-SKIPPED/DA-AD-SKIP-SUPPRESSED/DA-AD-CLICKEDdiagnostic codes are registered. Unit-tested inDolbyAdsSkipClickTest(the Kotlin mirror ofDolbyAdsSkipClickTests.swift).
Fixed
Pre-roll breaks never fired on iOS. The iOS
DolbyAdssession layer never calledBreakScheduler.markSessionStart(), sosessionStartMsstayed nil andcheckPreRollBreakbailed on every tick — everyposition: "pre"break silently never played (web calls it atpackages/core/src/DolbyAds.ts:513; the conformance CLIs call it themselves, which masked the gap).startSession()now anchors the session clock beforeupdateManifest, mirroring the TS reference and the Android fix onfeatures/android-sdk; regression-tested inPreRollSessionStartTests. Found by the on-device E2Epre-roll/pre-roll-delayedscenarios.Pause-position breaks were admitted into timeline scheduling (all cores). No core's
BreakSchedulerskippedposition: "pause"breaks, so their numericstarttriggered them as linear breaks — benign-looking on web (extractAssetsreturns no assets, but a spuriousadbreakbegin/adbreakendpair still fired), and actively harmful natively wheretoSeqBreakfed the pause variant's assets into real playout.updateManifestnow never tracks pause breaks (they arePauseAdController-owned) in the TS reference and Swift core, conformance-locked by the newpause-break-not-scheduledfixture; Kotlin backfilled 2026-07-29 (same one-line guard in the KotlinupdateManifest; thependingFixturesskip is removed fromconformance/adapters/kotlin/core.jsonand--cores=ts,kotlinpasses 106/106). Found by the on-device E2Epause-ad-vastcrash.iOS: client-side IMA construction crashed off the main thread, and requests failed with a nil
adContainerViewController.ImaVastAdManager.playVast(CSAI) andGamStreamManager.initialize(DAI) builtIMAAdsLoader/IMAAdDisplayContaineron the caller's cooperative-pool executor — IMA attaches anIMAWKWebViewto the view hierarchy during loader init, which asserts (SIGABRT) off the main thread. Both are now@MainActor-isolated. IMA additionally refuses requests when the display container's view controller is nil ("Ads cannot be requested…"), so both surfaces resolve the owning view controller from the ad container's responder chain; IMA errors now include the numeric type/code inDA-VAST-IMA-ERRORfor triage. With these fixes the full-tier linear VAST scenarios play end-to-end on the simulator — note iOS IMA blocks cleartexthttp://ad tags as mixed content, so full-tier runs need an httpsE2E_VAST_TAG(documented ine2e/ios/README.md).iOS:
AVPlayerAdapter.currentTimereported the stale pre-seek position while a seek was in flight, re-firing just-completed chained breaks.AVPlayer.seek(to:)is asynchronous andcurrentTime()keeps returning the old position until it lands — but the portable core is written against the web contract where assigningvideo.currentTimeis reflected synchronously (ExoPlayer matches too). The scheduler could tick inside that window and misread the SDK's own post-break resume-seek as a user rewind below the cue, dropping the PLAYG-241 guard and looping the chained breaks endlessly (found by e2e B5 on iPad). The adapter now reports the pending seek target ascurrentTimeuntil the seek completes, with a monotonic generation counter so a superseded seek's completion never unmasks a stale position (andload()clears any in-flight target). Regression-tested inAVPlayerAdapterSeekTests.E2E fixture media served SVG creatives no native platform can decode; the iOS e2e pause probes were untappable on iOS 26. Two harness defects surfaced by the first revived simulator run: (1) the mock's image creatives (
companion/backdrop) were SVGs — iOSUIImage/AndroidBitmapFactorycannot decode SVG (the manifest spec explicitly anticipates this), so every image-asset scenario raisedaderroron iOS; the mock now serves PNG rasterizations (e2e/media/*.png, the.svgsources remain the editable originals). (2) The demo's hiddene2e-pause/e2e-resumeXCUITest probes were squeezed to zero width by their 1×1HStackand iOS 26's XCUITest refuses to tap zero-sized elements (kAXErrorCannotComplete); each button now keeps a small non-zero frame with hit-testable opacity (ios/DolbyAdsDemo/DolbyAdsDemo/ContentView.swift). With these fixes the extended-tier iOS run is green for 12/17 scenarios; the remaining failures are SDK defects found by the suite (pre-roll never fires on iOS/Android —markSessionStart()is never called by the nativeDolbyAdsSDK layers; and a VAST pause-break crash inImaVastAdManager.playVastconstructingIMAAdsLoaderoff the main thread) — tracked as bugs, not fixed in this change.Native on-device E2E suites were orphaned — the iOS XCUITest and Android instrumentation drivers had lost their fixtures and mock backend. The PLAYG-213 (S14) removal of the legacy Playwright suite also deleted the shared scenario fixtures (
e2e/scenarios/*.json), the mock manifest backend (e2e/mock-server), and the locally-served media (e2e/media) that the native drivers —ios/DolbyAdsDemo/DolbyAdsE2E/ScenarioUITests.swiftandandroid/dolbyads-demo'sScenarioInstrumentedTest— still depend on, leaving the native iOS/Android SDKs with no runnable end-to-end coverage. Restored all three verbatim from the pre-removal tree (fc08b6a~1) plus a slimmede2e/package.json(mock + its tests only; the Playwright/BrowserStack web parts stay removed — web E2E remains Xagget-only inpackages/test-app). Drift audit: both native drivers and demo apps are untouched since the fixtures froze, and the SDK changes since (PLAYG-276/277/280/281) are additive to the event stream, so all 22 fixtures are restored unchanged. Docs:e2e/README.md(the folder is the native-E2E backbone again),e2e/ios/README.md+e2e/android/README.md(stale web-suite references updated),ios/README.md+android/README.md(run recipes), anddocs/e2e-test-plan.md(native-suites note distinguishing them from the Xagget desktop-web matrix). No SDK/core change.E2E orchestrator over-serialized selector-mode platforms sharing an OS, ignoring multi-host fleet capacity (PLAYG-329).
hostKey()(packages/test-app/src/runner/orchestrator.ts) grouped every selector-mode platform sharing an OS into one serial queue (os:mac,os:win) — a holdover from when there was exactly one physical lab host per OS. The fleet now has 3 Mac hosts and 2 Windows hosts, so e.g. mac-chrome and mac-safari ran one after another needlessly. Fixed: only platforms sharing an explicit directedinstallerId(genuinely the same physical machine) serialize now; selector-mode platforms run fully in parallel — the allocator already handles real same-host contention (DEVICE_BUSY/CAPACITY_EXCEEDED, XAG-153/154). Test-app/E2E-only; no SDK behaviour change.E2E
multi-break-spacedscenario blocked by browser autoplay policy on shaka (PLAYG-328).defineSpacedBreaksScenario(packages/test-app/src/scenarios.ts) calledplay()unmuted with no preceding user gesture; desktop browsers block that, so bothspaced-breaks-static-vod-darandspaced-breaks-vast-vod-darerrored (HANDLER_FAILED) on all three PLAYG-238-swept shaka platforms (MAC/Chrome, MAC/Safari, Win/Edge). Same root cause asdouble-audio-focus(PLAYG-292); fixed the same way —setMuted({ muted: true })beforeplay(). Test-app/E2E-only; no SDK behaviour change.
Changed
- Agent communication style replaced: terse "Bumba" → clear & explanatory. The repo-wide default for agent output (chat, plans, Jira comments, PR descriptions) is no longer the token-minimising Bumba style but a clarity-first style: every response shows what was done, why, and what happens next; chat responses that conclude work or ask a question open with a short status update (Working on / Done–pending / Next step); Jira writes stay compact and to the point; plans/RCA/Idea tickets keep their required "API & interfaces + functional flow" content.
docs/bumba-communication-style.mdis replaced bydocs/communication-style.md; all references updated (AGENTS.md,.windsurf/workflows/develop.md,.devin/playbooks/develop.md,docs/plan-requirements.md,docs/develop-workflow-details.md). Process-only; no SDK change.
[0.37.0] - 2026-07-27
Fixed
Per-URI
deviceTypeasset targeting was unimplemented — the SDK always playeduri[0](PLAYG-317).AdPlayerController.resolveUrireturneduri[0].valueunconditionally for astatic/vastasset'sAssetUri[], ignoring each entry'stargeting.deviceType— a manifest with per-device creatives (e.g. a TV-only URI alongside a desktop-only one) silently served the same URI to every device class. Resolution now goes through the new portableresolveTargetedUri(uri, deviceType)(packages/core/src/services/resolveTargetedUri.ts): an entry whosetargeting.deviceTypematches wins; otherwise the manifest's untargeted (default) entry is used; otherwiseuri[0](so playback still proceeds). The device type itself is detected once per session from the UA via the new web-onlydetectDeviceType(packages/core/src/services/detectDeviceType.ts) — analogous UA-sniffing glue to the existingisSharedElementUserAgent/isSingleDecoderUserAgentheuristics, not portable brain behaviour. Conformance-locked on TS (asset-uri-device-targetingfixture); Kotlin/Swift don't yet modelAssetUri[]/Targetingin their portableAssettype (deferred to "the player phase" per their own doc comments), so parity is tracked as a pending gap (conformance/PENDING-PARITY.md,pendingFixturesskip) until each native core's asset model adds it.AdPlayerController.deviceTargeting.test.ts's twoit.failingPLAYG-313 repro cases now pass, plus a new fallback-to-default test.Quartile events (
adfirstquartile/admidpoint/adthirdquartile) never fired forstaticassets (PLAYG-318).AdPlayerController's ad-playertimeupdatehandler only forwardedonAdTimeUpdate; quartile callbacks were only ever raised from the GAM pod-serving path (GamStreamManagerIMA callbacks) and the VAST/CSAI path (VastAdManager), never for a plainstaticmedia asset — any analytics/tracking relying on quartiles got zero signal for static creatives even thoughadtimeupdateticked normally. Thetimeupdatehandler now computes 25/50/75% progress itself forstaticassets only (each threshold fires once per asset, reset when the next asset starts) and raises the sameGamEventCallbacksquartile callbacks used by the other paths; GAM/VAST assets are explicitly excluded (they already get correct per-ad quartiles from IMA, which a raw-progress computation would either duplicate or miscompute for a multi-ad pod).packages/core/src/services/AdPlayerController.ts; regression tests inAdPlayerController.staticQuartiles.test.ts(flipped from theit.failingPLAYG-314 repro, plus a new VAST-exclusion test).double/lshape_ad/lshape_contenttransition-out briefly went black instead of smoothly revealing content (PLAYG-320).restoreLayout()resetplayerContainer's inline style to the base style, which has noz-index, right as the break ends — for the 300ms fade-out,playerContainer(nowz-index:auto) fell behind the still-visible, still-fadingadContainer(z-index:100) and companion backdrop (z-index:99), so the content box growing back to full screen was hidden underneath them and only snapped into view once they were torn down at the end of the fade.restoreLayout()now keepsplayerContainerat its break-timez-index:101for the duration of the transition for these three formats (position/backdrop-repositioning formats only —single/overlayare unaffected, pure opacity crossfades), clearing it back to the resting state once the fade completes.packages/core/src/services/AdPlayerController.ts(restoreLayout,endBreak,stopActiveBreak); regression tests added toAdPlayerController.test.ts.
Added
- E2E:
multi-break-spacedrepro canary for external e2e hang findings (PLAYG-309/PLAYG-311). Two new fixed-name Xagget scenarios,spaced-breaks-static-vod-darandspaced-breaks-vast-vod-dar(packages/test-app/src/scenarios.ts,defineSpacedBreaksScenario), schedule two 10s mid-roll breaks ~14s apart (content genuinely resumes between them) mirroring an externally reported page/device hang. Live-swept on MAC/Chrome: static 3/3 passed, VAST 4/4 passed — not reproducible on 0.36.0. Kept as permanent regression canaries (docs/e2e-test-plan.md). Newmulti-break-spacedFeatureId/FeatureSpecinrunner/matrix.ts, matching alias inrunner/scenario-id.ts. - Core: regression tests reproducing two external e2e findings (PLAYG-309).
AdPlayerController.deviceTargeting.test.ts(PLAYG-313) provesresolveUrialways returnsuri[0], ignoring eachAssetUri'stargeting.deviceType;AdPlayerController.staticQuartiles.test.ts(PLAYG-314) proves the static-assettimeupdatepath never raisesadfirstquartile/admidpoint/adthirdquartile(only the GAM/VAST IMA callback paths do). Both are kept asit.failingregression artifacts for their linked implementation tickets (PLAYG-317, PLAYG-318); no behaviour change in this PR.
Changed
- Docs: creative CORS/HEAD requirement, fast pre-roll completion, and DAR
resumeOffsetsemantics (PLAYG-309/PLAYG-316). Three "sharp edges" surfaced by external e2e testing are now documented: (1)packages/demo/docs/12-manifest.mdgains a prominent note that every asseturi(static media, VAST tag + referenced media, companion/pause images) is fetched directly by the browser/IMA and so the hosting server must send permissive CORS headers and answerHEAD, with theMEDIA_ELEMENT_ERROR: Format errorsymptom called out; (2)packages/demo/docs/21-adbreakstatus.mdexplains that adelay: 0pre-roll can complete its whole lifecycle within a fraction of a second ofstartSession()returning, and recommends registeringadbreakstatus/ad-event listeners before callingstartSessionrather than after; (3)packages/demo/docs/07-session.mdconfirms DAR's resume-only-with-explicit-resumeOffsetbehaviour is intended, not a bug, with a one-line explanation of the model. Docs-only; no SDK/core change. - E2E: Xagget device requests now use the
adssdkfleet selector tag.packages/test-app/src/runner/orchestrator.ts's selector-form device request addstags: ['adssdk']so runs use any lab device reserved for this project without needing to know installer IDs; documented indocs/e2e-device-runs.md("Fleet tag:adssdk"). DirectedinstallerIdrequests are unaffected (tags apply to selector requests only). Tooling-only; no SDK/core change. - E2E runner: migrated to Xagget allocator selectors (XAG-153).
npm run e2e -w @dolby-ads/test-appnow requests devices by default via an allocator selector ({ platform: 'web', os, deviceName }, XAG-133/XAG-154) instead of a hardcodedinstallerId— the allocator matches whichever lab host advertises that OS + browser, so no per-installer tagging was needed. The directed form (name the exact lab machine) is kept as a fallback: settingE2E_MAC_INSTALLER_ID/E2E_WIN_INSTALLER_IDpins that OS's platforms back to it (local installer runs, allocator outage).packages/test-app/src/runner/platforms.ts+orchestrator.ts;@xagget/device-sdk/@xagget/publish/@xagget/runner-sdkbumped 0.15.0 → 0.17.0. Tooling-only; no SDK/core change.
[0.36.0] - 2026-07-26
Added
- New customer demo: Bell Media (PLAYG-307). A third public customer demo page,
/bellmedia.html, following the GloboPlay pattern: a viewer-selectable content player (HLS.js or Shaka), server-side mid-roll ad breaks fetched from a live ad-manifest channel (manifestBaseUrl+startSession({ channelId })), and a client-injected pre-roll (Single/Double/L-Shape/Overlay) and pause ad layered on top viainterceptManifestResponse+ the sharedinjectPreRoll/injectPausehelpers. The linear pre-roll formats fill from the same GAM VAST pod tag as GloboPlay/Bally's; unlike GloboPlay's single backdrop image, the Double and L-Shape formats additionally carry their own Bell Media companion artwork (bell-doublebox.png/bell-lbar.png) as the picture-in-picture backdrop. The Overlay pre-roll 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. Bell Media is a light brand (white surface, Bell blue#0065a4accent sampled from the logo). New registry entry (packages/demo/src/customer-demo/registry.ts), demo page + script (packages/demo/bellmedia.html/src/bellmedia.js), self-hosted assets underpackages/demo/public/bell/, and a new public/no-login page invite.config.js'sSITE_GATE_PUBLIC_FILES+ Vite build input. The portal (/portal.html) picks up the new tile automatically from the registry. Demo-app-only; no SDK/core change.
Changed
- Customer demo explainer: Delayed pre-roll callout + labelled break-detection arrow (PLAYG-303). Follow-up to the PLAYG-294 "How it works" explainer on the Bally's and GloboPlay pages. The capabilities matrix's Features group now includes a Delayed pre-roll pill (the break
delaycapability the demo pages already expose via their pre-roll delay input), and the architecture diagram's dashed Ads API → Origin arrow — previously unlabeled — now carries an explicitBreak detectionlabel so the diagram tells the marker-detection story even when the reader skips the text above it (it stays dashed to signal it is optional).packages/demo/src/customer-demo/explainer.ts+explainer-diagrams.ts; demo-app-only, no SDK/core change.
[0.35.0] - 2026-07-24
Added
- Demo site: site-wide login gate (PLAYG-295). Every demo page — the root demo, docs, and every internal page (Pre-roll, VAST, VOD, Pause, DVR, Manifests, Preset Builder, Bug Report), including the customer-demo portal (
/portal.html) — is now behind a single sign-in page (login.html, credentialsoptiview/bumba), replacing the portal's old standalone password check. A newsiteGatePlugin(packages/demo/vite.config.js) injects a head-time inline redirect script at build time on every page except a public-file allowlist (login.htmlplus the two customer demos, Bally's and GloboPlay, which stay public with no login). Credentials + the persisted unlock (localStorage['da-site-unlocked'], so a visitor only signs in once per browser) live in the newpackages/demo/src/site-gate.ts; the redirect target is sanitized (sanitizeNext) against open-redirect payloads.?e2e=1bypasses the gate for the demo's own automated tooling. Demo-app-only; no SDK/core change. New Playwright suitepackages/demo/e2e/gate.spec.ts+pages.spec.ts(51 tests: gate redirects, login form, and one boot/lifecycle check per page) and unit testspackages/demo/src/__tests__/site-gate.test.ts. Seepackages/demo/docs/20-customer-demos.md("The portal") and the rootREADME.md(Demo section). - Customer demo pages: shared "How it works" explainer (PLAYG-294). GloboPlay and Bally's now render a brand-themed explainer band below the working demo: a combined architecture + integration diagram, how the SDK wraps a third-party player plus a copy-pasteable minimal web setup snippet with the ad-break events (real JS/TS keyword/string/function-call syntax highlighting, VS Code Dark+-style palette, via a small hand-rolled tokenizer — not just colour on the comments), the AI onboarding tooling, and a features/ad-formats/players/streaming/insertion capabilities matrix. Copy leads with the Dolby Optiview Ads brand (matching the messaging style of optiview.dolby.com) and pairs a plain-language paragraph with a developer-level paragraph per section. New shared module
packages/demo/src/customer-demo/explainer.ts(purerenderExplainer+mountExplainer, mobile-responsive: diagrams scroll at a fixed readable width below 720px instead of shrinking illegibly) andexplainer-diagrams.ts— a single simplified SVG diagram (renderArchitectureDiagram): three ownership lanes with a one-word legend (Customer / Dolby / Ad Provider — the ad-serving side is never named as a specific vendor), the ad-serving nodes consolidated into oneAd Server(the Ads API'sNotifyarrow enters near the top-left, the Ads SDK'sAd callarrow enters from the bottom-centre so the two touchpoints read as clearly different — no stitcher/SSAI/EABN terminology), the standalone "Operations" node dropped (the Dashboard now wires directly to the Ads API forConfigure & schedule; the Ads API/Origin break-detection link is drawn dashed since it's optional and routed through the gap between the lanes so it can't be misread as an Origin↔Dashboard arrow), the Ads SDK → Player arrow labelledAd Insertion,Mgnt Dashboard/ADS Client API/the break-CDN renamed toDashboard/Ads API/Ads SDK,Your Player + SDKsimplified toPlayer, the customer CDN node and the Origin's "(3rd-Party)" qualifier dropped, Origin/Ads API/Ad Server and Player/Ads SDK aligned on shared rows so every cross-lane arrow (the unlabeled dashed Ads API → Origin break-detection link,Notify,Ad Insertion) and the verticalContentarrow is one straight line, and the three customer integration touchpoints (configure, connect your stream, add the SDK) numbered directly on the Dashboard/Origin/Player nodes instead of a separate dimmed diagram. Copy uses the approved Dolby OptiView Ads messaging ("monetized experience … streaming workflow unchanged … Everything else is managed by Dolby."), is plain-language only (no "Under the hood"/"For developers" layer, no dash punctuation) and every section follows a Title → explaining paragraph → image order with the three touchpoints as a vertical ordered list. The band opens with a glowing labelled divider plus a large top margin, deliberately separating the Dolby story from the differently styled demo above (the low-value four-link anchor nav and the per-arrow flow list were dropped). The whole band sits in one rounded, bordered container (24px radius, soft shadow) that follows the page's brand theme (light card on Bally's, dark card on GloboPlay) so it stands out as a single unit without clashing with the page; sections carry just a headline (no eyebrow labels — the divider is the band's single "How it works" title); the SDK section explains the adapter approach with only HLS.js named as the example player; the AI section explains the AI enablement in full (local knowledge, diagnostic-report troubleshooting, and a player adapter skill for uncovered players) with no cross page links. Capabilities grew to seven groups: Break scheduling (SCTE-35, EXT-X-CUE, EXT-X-DATERANGE, API, Dashboard) and Ad controls (Countdown, Number of ads, Skippable, Snapback) were added, Pause ads split into "(video)" and "(image)" pills, Streaming lists HLS/MPEG-DASH/HESP alongside Live/DVR/VOD, Insertion adds VAST, and the Players and Streaming groups each end with a muted grey "Any" pill signalling an open-ended list. The capabilities matrix now colours each group with its own accent/tint instead of one flat grey pill (plain text pills, no player logos), splits DAI/DAR into their own "Insertion" group (separate from Streaming, tags only — no prose explanation), addsL-Shape Content(the backdrop-onlylshape_contentformat) alongsideL-Shape, and names the actual players (Media3 / ExoPlayer,AVPlayer) instead of generic OS names. The separate SDK and Code sections are merged into one, and the customer-specific closing "This demo" section is dropped, somountExplainer()now takes no arguments. Themed via the existing--brand-*tokens; the diagram sits on a fixed light panel so the semantic colours read on GloboPlay's dark brand and Bally's light one.packages/demo/docs/20-customer-demos.mddocuments the explainer contract for future customer demos. Demo-app-only; no SDK/core change.
Fixed
- First-frame-gated pre-roll dropped
adbreakbeginwhen the ad failed to load, leaving an unbalanced lifecycle (PLAYG-291). For an immediate (delay:0) fullscreen pre-roll (single/lshape_ad), the web SDK holds content and defersadbreakbeginuntil the ad's first frame renders (so the AD badge/black overlay never flash before the media is visible). If the creative failed to load, the first frame never arrived, soadbreakbeginwas never emitted — yetaderror/adend/adbreakendstill fired, producing anadbreakendwith no matching begin.DolbyAds.emitAdBreakBegin()now emitsadbreakbeginat most once per break and is flushed before any terminal event (aderrorin the ad-error callback,adbreakendinonBreakEnd), so a gated pre-roll is always bracketed byadbreakbegin … adbreakend. Web (@dolby-ads/core) only — the first-frame gate is web-specific; the Kotlin/Swift cores emitadbreakbeginunconditionally at break start and the sharedAdBreakSequenceralready pins the canonical order (conformance fixturesdk-overlay-aderror), so no brain/native change is needed. Regression test:packages/core/src/__tests__/DolbyAds.breakBeginGate.test.ts. Found by the PLAYG-251 hls.js/MAC-Chrome E2E sweep (neg-static-http-error). - Demo pages: control bar unresponsive and never fading during an ad break (PLAYG-289). A regression surfaced after PLAYG-284/288: during any break (most visibly a
double-format mid-roll) the play/mute control bar stayed visible but its buttons did nothing, and the bar never auto-hid. Two independent causes, both demo-app-only. (1) After PLAYG-288 made the control bar a sibling of the SDK'splayerContainer, the bar kept its oldz-index: 5, below the SDK's internal ad-overlay/companion layers (up toz-index: 101); the overlay stage painted over the bar so clicks landed on the invisible stage. Fixed by raising the bar'sz-indexto200(matching the break-toast) on all 7 demo pages (index.html,globoplay.html,ballys.html,vast.html,dvr.html,preroll.html,vod.html). (2) Three pages (globoplay.js,ballys.js,main.js) calledshowControls()from theadbreakstatushandler, which fires continuously to drive the countdown, so the 2.6s auto-hide timer was restarted every tick and never elapsed; moved that one-shot call into theadbreakbeginhandler (fires once per break). No SDK/core change.
Changed
- GloboPlay demo: new backdrop image for the Double-box and L-Bar pre-rolls (PLAYG-290). The companion region of the
doubleandlshape_adpre-rolls ongloboplay.htmlnow uses a dedicated GloboPlay backdrop (packages/demo/public/globo-backdrop.png, served as/globo-backdrop.png) instead of reusing the brand logo URL. The overlay pre-roll and pause ad are unchanged. Demo-only; no SDK change.
[0.34.0] - 2026-07-15
Fixed
- Demo pages: custom control bar and break toast shrank into the pip box during
double/L-shape ad breaks (PLAYG-288). All 7 demo pages (index.html/main.js,globoplay,ballys,vast,dvr,preroll,vod) either omitted the SDK'splayerContainerconfig option or included the control bar inside it. Since the SDK repositions/resizesplayerContainerinto a pip corner fordoubleand L-shape breaks, the control bar and break-toast shrank along with the video instead of staying full-size. Fixed by wrapping ONLY the content player element(s) (<video>, and#theoPlayerElwhere used) in a dedicated#playerContainerdiv passed explicitly toDolbyAds({ playerContainer }), and moving the control bar / break toast to be direct siblings of the outer#container(matching the existing#container16:9 sizing, so normal single-box playback is visually unchanged).packages/demo/docs/10-player-ui.md's example markup updated to match. Demo-app-only; no SDK/core change — this is about how demo pages consume the existingplayerContainerconfig option. - Bally's demo: ad break started (and therefore ended) one media segment early (PLAYG-282). Root cause:
parseCueOuts(packages/scte35-bridge/src/parser.ts) computes a bare#EXT-X-CUE-OUTbreak's start as the nearest#EXT-X-PROGRAM-DATE-TIMEplus the#EXTINFdurations of segments in between, but reset the pending#EXTINFduration whenever it saw a PDT line. Bally's playlist orders tags#EXTINF→#EXT-X-PROGRAM-DATE-TIME→ segment URI (the PDT's own segment's duration comes first), so that reset discarded the PDT segment's own duration, pinning the computed segment clock at the start of that segment instead of its end — every break fired (and its window ended) one segment early (~1–2s on this stream, varying with segment length). Removed the reset; it was a no-op for the opposite (PDT-first) tag order this code was originally written for, and is correct for both orderings. Added regression tests inparser.test.tscovering Bally's exact tag order. Demo/bridge-package fix — not portable brain, no conformance fixture. - Live GAM mid-rolls: break re-fired after completing, jumpy countdown badge, and occasionally a break that never fired (PLAYG-280). Root cause: the HLS.js adapter's
timeupdatePDT interpolation computedfirstFrag.programDateTime + currentTime— missing- firstFrag.start— so on a live (sliding) playlist the reported Program Date Time ran ahead of the true value by the window's slide (growing over the session) and snapped back on everyFRAG_CHANGED/LEVEL_LOADED(the latter anchored PDT to the window start, ignoring the playhead). Latent since the adapter's first version, it became a regression when PLAYG-221 madeBreakSchedulerconsume PDT as the wallclock clock: each backward snap read as a DVR rewind and re-armed + immediately re-fired the just-completed break; the oscillation drove theadbreakstatuscountdown; and a forward jump larger than the 5 s trigger tolerance could hop the whole trigger window so a break was silently skipped. Fixes: (1)HlsJsAdapternow computes all three PDT paths through one position-anchored helper (refFragPdt + (currentTime - refFragStart)), with sliding-playlist regression tests; (2) defense in depth in the portable brain —BreakScheduler.rearmOnRewind()ignores backward steps ≤ 2 s (REWIND_JITTER_TOLERANCE_MS) on the wallclock timebase only, so stream/adapter PDT jitter can never replay a finished break while a genuine multi-second DVR seek-back still re-arms (PLAYG-224) and PTS behavior is untouched (PLAYG-241). Brain change mirrored in the Kotlin and Swift cores with unit tests and locked by the new conformance fixturewallclock-rewind-jitter-suppress. ThePlayerAdaptercontract doc now requiresprogramDateTimeto be position-anchored and continuous. Shaka's PDT (presentationStartTime + currentTime) was already correct.
Added
- Configurable double-box audio focus (PLAYG-286). New
doubleBoxAudioconfig option ('ad' | 'content', default'ad') controls which side is audible during adouble-format break, where the content and ad boxes play side-by-side. The non-focused side is muted for the break's duration and the pre-break unified state is restored to it at break end.AdPlayerControllertracks the active break'saudioFocus; the unifiedsdk.muted/sdk.volume(PLAYG-285) target the focused side only, and the existing content<->advolumechangesync is suspended for the duration of a focused break so it cannot fight the focus rule. Exposed in the Bally's demo (packages/demo/ballys.html) as an "Ad" / "Content" toggle next to the mid-roll format selector. Unit tests cover the default andcontentfocus, themutedsetter's focused-side targeting, and the break-end restore. Web (@dolby-ads/core) only for now. - Unified mute/volume API — one audio control for content and ad playback (PLAYG-285). Fixes a demo bug report: the ad's Unmute control was not reachable on the pre-roll because the demo pages hide their whole control bar during a break.
DolbyAdsnow exposessdk.muted(boolean) andsdk.volume(0-1) getters/setters that always target whichever player is currently audible — the ad player during a break, the content player otherwise — plus a newvolumechangeevent so a UI can keep a mute button's label correct without polling. Internally,AdPlayerControllergains the samemuted/volumeAPI and asetVolumeChangeCallbackhook; the existing content<->advolumechangesync now also notifies this callback, and a VAST/IMA CSAI ad in progress is bridged via a newVastAdManager.setVolume()(mirroring the existingsetMuted()) since IMA manages its own audio path separately from the underlying<video>element. GAM pod-served ads needed no separate bridging — they play through the ad player's own<video>element, so the existing element-level mute/volume already reaches them. Web (@dolby-ads/core) only for now; Android/iOS parity is tracked as a follow-up. Part of the PLAYG-283 epic (unified audio API consumed by all demos). - Native break countdown now ticks from real ad playback (PLAYG-281). Completes the native ad-break status work (PLAYG-276/277): the Android (
android/dolbyads-runtime) and iOS/tvOS (ios/DolbyAdsRuntime)OverlayAdRenderers now feed real ad playback progress into the SDK'sonAdTimeUpdate, soAdBreakStatus.breakRemainingSeccounts down from actual playback instead of the wall-clock fallback. For media (static / GAM vendor) assets the renderers poll the ad player every 250 ms — Android an ExoPlayerHandlerpoll ofcurrentPosition/duration, iOS anAVPlayer.addPeriodicTimeObserver. For VAST/CSAI (IMA) assets a newVastAdEventCallbacks.onAdProgress(currentTimeSec, durationSec)is forwarded: on Android from the IMAAdVideoPlayer's existing 250 ms progress poll, on iOS (which has no per-frame IMA progress event) from a 250 ms timer anchored toIMAAd.durationat.STARTEDand frozen/resumed on IMA.PAUSE/.RESUME. Progress is attributed to the break that owns the currently-playing asset (chained breaks included). The forwarding seam is unit-tested on both platforms with a fake VAST manager (OverlayAdRendererVastTest.kt,OverlayAdRendererVastTests.swift); the real media/IMA path is runtime/IMA-bound (no headless mode) and verified on device/simulator. No SDK-core change — the SDK already consumesonAdTimeUpdate(PLAYG-276/277). The web E2Ebreak-statusscenario (packages/test-app) now also asserts the countdown ticks (breakRemainingSecdecreases +ticking), anddocs/e2e-test-plan.md+conformance/PENDING-PARITY.mdrecord the native feed as done. - Native ad-break status API on Android and iOS/tvOS (PLAYG-276, PLAYG-277). The
adbreakstatusevent andgetAdBreakStatus()API — already shipped on web in PLAYG-266 — are now mirrored in the Kotlin (android/dolbyads-sdk) and Swift (ios/DolbyAdsSDK) SDKs, so a native player UI has the same single source of truth for break state and countdown. Each core gains theAdBreakStatusmodel (phaseidle/upcoming/active/complete,secondsUntilBreak,breakRemainingSec,adIndex,totalAds,adsRemaining,ticking,warningSec), anadbreakstatusevent type +AdBreakStatusEvent, abreakWarningsconfig option plumbed into the coreBreakScheduler, and the phase state machine + playback-gated countdown assembly ported 1:1 from the webDolbyAds.ts— including the PLAYG-266 no-rewind baseline (the countdown never jumps15 → 14 → 15) and the wall-clock fallback that advances the countdown before real ad progress arrives. The wall-clock source is an injectableClockso unit tests freeze time. New unit tests (DolbyAdsAdBreakStatusTest.kt,AdBreakStatusTests.swift) cover active-on-break-start, ticking on ad playback, the no-rewind case, and theupcomingpre-break warning. The portable warning schedule stays conformance-locked by the existingbreak-warningfixture, so the2026-07-02row inconformance/PENDING-PARITY.mdis now green for all three cores. Not included (tracked as an on-device follow-up): wiring real IMA/ExoPlayer/AVPlayer ad progress into the runtime renderers'onAdTimeUpdate— the native runtime renderers do not emit ad time updates for any ad type today, so the countdown currently advances via the wall-clock fallback; feeding real ad progress is runtime/IMA-bound (no headless test) and is verified on-device, consistent with the SSAI-orchestration and GAM-pod rows in the parity tracker.
Changed
- GloboPlay demo: updated content stream (PLAYG-279). Repointed the GloboPlay demo page (
globoplay.html/packages/demo/src/globoplay.js,CONTENT_URL) from the softvelum loop stream (https://ll-hls.softvelum.com/sldp/bbloop/playlist.m3u8) to the tokenized GLOBO low-latency HLS source (https://live-as-03-27.video.globo.com/j/<jwt>/live/f(ll)/testelowlatency/playlist.m3u8). The server-side mid-roll ad-manifest channel and the client-injected pre-roll/pause ads are unchanged. Demo-app-only; no SDK/core/brain change.
[0.33.1] - 2026-07-13
Fixed
- Preloaded VAST ads (e.g. a delayed pre-roll) now resize reliably — resize on the IMA
STARTEDevent, plus on container resize (PLAYG-275). Completes PLAYG-265. CallingAdsManager.resize()only beforestart()(PLAYG-265) is not always honoured by IMA, because the ad renderer does not exist yet — a preloaded ad such as a delayed pre-roll kept the stale slot size and rendered in a small top-left box.VastAdManagernow also callsresize()on the IMASTARTEDevent (the reliable point, once the renderer exists), andAdPlayerControllercallsvastAdManager.resize()from the containerResizeObserverso an active VAST ad stays filled through fullscreen / browser / split-screen resizes. Added unit tests for theSTARTED-event resize and the container-resize path. TS core (@dolby-ads/core) only — IMA CSAI is web-only, not portable brain, so no conformance fixture. - VAST/CSAI pre-roll: frozen countdown, countdown rewind, and content playing behind the ad (PLAYG-266 follow-up). Three runtime defects surfaced on the VAST demo pre-roll (verified by driving
vast.htmlin a real browser): (1) the break countdown (adbreakstatus.breakRemainingSec) was frozen because IMA never fires the ad video element'stimeupdatethat the media path relies on —VastAdManagernow subscribes to IMAAD_PROGRESSand forwardscurrentTime(= ad duration −AdsManager.getRemainingTime()) via a newonAdProgresscallback, whichAdPlayerController.playVastAssetwires toonAdTimeUpdateso the countdown ticks; (2) the countdown jumped backwards (15 → 14 → 15): before the first ad progress the countdown advances via a wall-clock fallback, butonAdStartedbaselined the ad against a staleactiveBreakElapsedSecof0, so the firstAD_PROGRESS(currentTime≈0) collapsed elapsed back to~0and remaining jumped back up —DolbyAds.onAdStartednow captures the wall-clock-inclusive elapsed at ad start (newcurrentBreakElapsedSec()helper, also used bycomputeBreakRemainingSec()) so the ad-time countdown continues monotonically; (3) content played underneath the ad (itscurrentTimeadvanced 0→15 during the pre-roll, and a play/pause race could leave it stalled/black afterward) because the pre-roll content hold was released atplayBreakwhile the app'sloadContent()play()resolved ~1–2s later (after the HLS manifest loaded) with no guard —AdPlayerControllernow installs a break-scoped content gate (gateContentForBreak) that re-pauses content on anyplayingfor the whole duration of a content-covering break, cleared atendBreak/abort/destroy before the resumeplay(). Content now stays gated during the pre-roll and resumes from the start. Regression tests added inAdPlayerController.vast.test.tsandDolbyAds.adBreakStatus.test.ts. - VAST/CSAI ads in preloaded double (and L-Shape) breaks now resize to their box instead of overflowing (PLAYG-265). The
VastAdManagerpreloaded path (preloadVast()ahead of break start, thenstartPreloaded()at break start) initialized the Google IMAAdsManagerwith the ad video element'sclientWidth/clientHeightfrom arming time — beforeapplyLayout()/reapplyAdContainerLayout()sized the ad container fordouble/lshape_adformats. The IMA slot was therefore much larger than the intended box, so the ad creative overflowed it. AddedVastAdManager.resize()and call it instartPreloaded()beforestart()so IMA re-reads the element's dimensions after the box is laid out. The on-demand path (playVast()at break start) also benefits, and the existingPLAYG-242re-stamp still applies. Added unit tests forVastAdManager.resize()and the preloadeddoublebreak path. - VAST/CSAI pre-rolls broke under the first-frame gate (PLAYG-263 regression). The PLAYG-263 first-frame gate holds the ad overlay hidden and defers
adbreakbeginuntil the ad's first frame renders, resolving that gate only from the media ad player'splayingevent (AdPlayerController.playAsset). IMA/CSAI never drives that event and — unlike the media path — needs a visible, sized container to render into (IMA reads the slot'sclientWidth/clientHeight). So a fullscreen-covering VAST pre-roll (single/lshape_ad) rendered into a hidden, zero-sized container: the ad never appeared, content stayed paused, andadbreakbeginnever fired — only theadbreakstatusbadge showed (surfaced by the new PLAYG-266 countdown on the Bally's demo).AdPlayerController.playVastAssetnow callsrevealFirstFrame()before IMA reads the slot size (a no-op when the gate is inactive/already shown), restoring the pre-PLAYG-263 immediate-overlay behavior for IMA while keeping the black-flash gate for media ads. Regression test added inAdPlayerController.vast.test.ts. - Main player page pre-roll injection used the wrong manifest field (PLAYG-264).
injectPreRoll/buildPreRollBreakinpackages/demo/src/presets.ts(used by the main Player page'sinterceptManifestResponsehook) built the injected break with avariantsarray, but the SDK's parsedBreakcarries a singularvariantfield (BreakVariant | BreakVariant[]) thatselectVariantreads. BecauseinterceptManifestResponsereceives the already-parsed manifest and its output is consumed directly (no re-parse), the injected pre-roll had no selectable variant and never played. Changed the demo'sParsedBreaktype andbuildPreRollBreakto emit the singularvariant, matching the customer-demoinject-adshelper. Thepreroll-injection.test.tsunit test now asserts thevariantfield the SDK actually consumes (it previously asserted the wrongvariantsshape, so it passed while playback was broken). Demo-app-only; no SDK/core/brain change.
Changed
- Bally's demo: disambiguated the format controls and split out the logo overlay. The page previously had two easy-to-confuse pickers — the prominent Mid-roll ad format segmented toggle and a separate pre-roll dropdown that also carried an
overlay(Bally logo image) option; selecting Double on the mid-roll toggle did nothing for the pre-roll, so a pre-roll played full-screensingle. The mid-roll toggle is now clearly labelled as SCTE-35 mid-rolls only; the pre-roll and pause controls are now segmented button rows matching the mid-roll design, each with a greyed Disabled segment selected by default (pre-roll:Disabled/Single/Double/L-Shape/Overlay; pause:Disabled/Image/Video). The non-linear Bally logo image overlay is the pre-roll row's Overlay segment (mutually exclusive with the linear VAST formats), which maps to thelogoOverlaymanifest option /buildLogoOverlayBreak()(distinct break idballys-overlay-logo) rather than a VAST pre-roll.ballys-manifest.tsgainsBallysVastFormat(the linear subset),buildLogoOverlayBreak(), and alogoOverlayoption;preRoll.formatis narrowed toBallysVastFormat. Demo-app-only; no SDK/core/brain change.ballys-manifestunit tests updated for the linear pre-roll, the new logo-overlay builder, and pre-roll + overlay coexistence. - GloboPlay demo: matching segmented pre-roll / pause controls. Applied the same pattern to
globoplay.html/globoplay.js: the pre-roll (Disabled/Single/Double/L-Shape/Overlay) and pause (Disabled/Image/Video) checkboxes + dropdowns are now segmented button rows with a greyed Disabled segment selected by default, styled for GloboPlay's dark theme (orange→red accent for the active segment, grey for Disabled). GloboPlay's pre-rollOverlaystays a pre-roll format (itsinjectPreRollalready supports the non-linearoverlay), so no manifest-shape change was needed. Demo-app-only; no SDK/core/brain change. - GloboPlay logo overlay: tunable placement, nicely inset top-right.
injectPreRoll'sPreRollOptionsgains an optionaloverlayfield (position/size/opacity, fractional 0–1) so a customer demo can place its overlay logo; the default is unchanged (top-right badge{ top: 0.05, right: 0.05 },{ width: 0.28, height: 0.14 }, opacity0.95). GloboPlay now passes a compact top-right box ({ top: 0.04, right: 0.04 },{ width: 0.24, height: 0.09 }) for its wordmark. The SDK renders the overlay imageobject-fit: contain, so the box is sized to the logo's own aspect ratio (the source PNG is 4096×861 ≈ 4.76:1) to avoid vertical letterbox slack that would push the wordmark toward the middle of the box.inject-adsunit tests cover the default and the override. Demo-app-only; no SDK/core/brain change.
Added
- Docs: dedicated "Break Status & Countdown" page (PLAYG-266 follow-up). New
packages/demo/docs/21-adbreakstatus.md(rendered atdocs.html#adbreakstatus, linked from a new How-To sidebar item) explains theadbreakstatusevent /sdk.getAdBreakStatus()snapshot: what it is, the fullAdBreakStatusfield table and phases, how to subscribe/poll and drive a countdown frombreakRemainingSec+ticking, how to configure pre-breakupcomingwarnings viabreakWarnings: { seconds: [...] }, and how to use it for pre-rolls (noupcomingwarning,tickinggates the first frame, monotonic countdown). Docs-only. - Bally's & GloboPlay demos: configurable delay for pre-roll and pause ad (PLAYG-274). Both customer demo pages now expose a delay (seconds) number input next to the pre-roll and the pause-ad controls (each disabled until its feature toggle is on). The pre-roll delay is the seconds of content playback before the pre-roll fires; the pause-ad delay is the seconds the content must stay paused before the pause ad appears. Values are threaded into the manifest each demo builds/augments: Bally's via
assembleBallysManifest→buildPreRollBreak/buildPauseBreak(packages/demo/src/customer-demo/ballys-manifest.ts), GloboPlay viaaugmentManifest→injectPreRoll(already supported) andinjectPause. The reusableinjectPause/buildPauseBreakhelpers inpackages/demo/src/customer-demo/inject-ads.tsgain an optionaldelaySeconds(default 0) that sets the break'sdelay. Defaults stay 0, so existing behaviour is unchanged unless a delay is set. The SDK already honoursBreak.delayforposition: 'pre'andposition: 'pause', so this is demo-app-only; no SDK/core/brain change.inject-adsunit test extended for the pause delay; docs updated. - GloboPlay customer demo (PLAYG-262, epic PLAYG-252). New
globoplay.html+packages/demo/src/globoplay.js(registered as a Vite input and listed in the portal). It plays GloboPlay's low-latency HLS stream (https://ll-hls.softvelum.com/sldp/bbloop/playlist.m3u8) through a viewer-selected content player — HLS.js or Shaka (the shared player picker from PLAYG-260). Unlike Bally's, the mid-roll ad breaks come from a real ad-manifest endpoint: the SDK is configured withmanifestBaseUrl = https://optiview-ads-manifest-phx-1.staging.dolbyio.com/manifest/v1and started withchannelId = 44345881-ba25-4c57-a389-9810d2c9ecc8, so breaks punched server-side into that channel are fetched/polled over the network. An optional pre-roll (single/double/L-Shape filled by a 15s Google Ad Manager VAST pod, or a GloboPlay-logo overlay) and pause ad (a GloboPlay still image, or an MP4 video) are layered on top in the browser viainterceptManifestResponseusing the PLAYG-261injectPreRoll/injectPausehelpers, toggled on the page. The player picker sits directly under the player. The page wears GloboPlay's dark branding (black surface, orange→red gradient accent, GloboPlay logo) via the shell's dark-surface theming, includes in-player controls + a break-countdown toast + an on-page event log, and embeds the dormant Xagget device agent (like Bally's). Registered incustomer-demo/registry.tsso its portal tile appears. Guidelines doc gains a GloboPlay worked example (usingglobo.pngfor the pause illustration);README.mdand this changelog updated. Demo-app-only; no SDK/core/brain change. Verification is unit tests (picker, injection, registry) plus a manual browser check — Xagget E2E is best-effort/exempt as for Bally's (subject to stream/endpoint CORS + lab reachability). - Customer-demo pre-roll + pause injection on a live backend (PLAYG-261, epic PLAYG-252). New reusable, creative-agnostic helpers
packages/demo/src/customer-demo/inject-ads.ts(injectPreRoll,injectPause, plusbuildPreRollBreak/buildPauseBreakand theINJECTED_PREROLL_ID/INJECTED_PAUSE_IDguards) let a customer demo that gets its mid-rolls from a real ad-manifest endpoint still demo a pre-roll and a pause ad by injecting them into the already-parsed manifest via the SDK'sinterceptManifestResponsehook.injectPreRollprepends aposition: 'pre'break (formatssingle/double/lshape_ad, static or VAST, plus a non-linear logooverlay);injectPauseappends aposition: 'pause'/format: 'pause'break (image or video). The caller supplies the creatives so each customer uses its own artwork/tags. Both are idempotent (keyed by the injected break id — the hook runs on the initial fetch and every poll, so re-injecting never duplicates the break), never mutate the input, and preserve the server's mid-roll breaks. Guidelines doc updated; unit tests cover prepend/append, format/VAST/companion shaping, idempotency, non-mutation, and mid-roll preservation. Demo-app-only; no SDK/core/brain change. - Customer-demo building blocks: content-player picker + dark-surface/gradient shell theming (PLAYG-260, epic PLAYG-252). Groundwork for the GloboPlay demo. A new shared player picker (
packages/demo/src/customer-demo/player-picker.ts) lets a customer demo offer HLS.js or Shaka as selectable logo buttons (CUSTOMER_PLAYER_CHOICES,renderPlayerPicker,mountPlayerPicker,playerChoiceToLib); the official HLS.js and Shaka logos are self-hosted underpackages/demo/public/players/(hlsjs.svg,shaka.png) rather than hotlinked, and shown on a white chip so they stay legible on dark buttons. The shell also now owns the shared customer-demo page layout and control sizing (two-column.pagegrid, card titles, intro/hints, toggles, buttons, player picker) as size-only rules injected after each page's own styles — so every customer demo (Bally's and GloboPlay) is consistently sized from one place, and Bally's picks up the larger layout automatically without editing its page. The shared customer-demo shell (packages/demo/src/customer-demo/shell.ts) was extended generically to support a dark surface with a gradient accent:CustomerBrandgains optionaltext,statusbarBg,cardBg,cardBorder, andoptiviewLogoFiltertokens (exposed as--brand-text/--brand-statusbar-bg/--brand-card-bg/--brand-card-border/--brand-optiview-filter),--brand-accentmay now be a CSS gradient (the status-bar accent stripe is painted on the border-box layer so gradients show there), andbrandVarsemits the new tokens only when a brand sets them — so brands that omit them keep the exact light rendering (Bally's is unchanged). Guidelines (packages/demo/docs/20-customer-demos.md) document the picker and the light-vs-dark tokens. Demo-app-only; no SDK/core/brain change. Unit tests cover the picker mapping/render/mount and the shell's light + dark theming.
[0.33.0] - 2026-07-12
Added
- Ad break countdown status API (PLAYG-266). New
adbreakstatusevent andgetAdBreakStatus()API give player UIs a single source of truth for break countdown.AdBreakStatuscarriesphase('idle' | 'upcoming' | 'active' | 'complete'),secondsUntilBreak,breakRemainingSec,adsRemaining,adIndex,totalAds, andticking. ConfigurablebreakWarnings: { seconds: number[] }emitsadbreakstatuswithphase: 'upcoming'as the playhead crosses each threshold (e.g.[10, 5]warns at 10s and 5s before a break). Implemented across all three cores — TS (@dolby-ads/core), Kotlin (android/dolbyads-core), Swift (ios/DolbyAdsCore) — with a new conformance fixture (break-warning) locking the warning behavior. Demo pages (vod,preroll,dvr,vast,main,globoplay,ballys) and docs (06-configuration.md,08-events.md) migrated to the new event. The native runtime orchestration layer (event emission +getAdBreakStatus()) is tracked as a parity gap inconformance/PENDING-PARITY.md. - CSAI VAST ad preload (PLAYG-253).
VastAdManagernow supports two-phase loading:preloadVast(adTagUrl, options)requests the VAST tag and creates the IMAAdsManagerwithout starting it, andstartPreloaded()begins playback at break start.AdPlayerController.preload()routes VAST assets topreloadVastAsset();playVastAsset()uses the preloaded manager when present and falls back toplayVast()when preload is skipped or failed. Added Jest coverage for the VAST preload lifecycle and fallback. Implemented in the TS core (@dolby-ads/core); Kotlin/Swift native cores do not implement VAST CSAI (IMA is web-only) and are unaffected.
Changed
- Bally's demo default VAST tag → GAM live pod endpoint, fill the full break (PLAYG-250). The prefilled
DEFAULT_BALLYS_VAST_TAGis now Google Ad Manager's live pod-serving endpointhttps://pubads.g.doubleclick.net/gampad/live/ads?sz=640x480&output=xml_vast3&iu=/23285652104/dcoffey-ad-unit&env=vp&impl=s&gdfp_req=1(was the single-ad sample tag). The page now opts into pod-duration params (currentManifestOptssetspodDuration: true).vastTagForBreakDurationsetspmnd=0andpmxd= the break duration floored to whole seconds (ms), so the pod fills up to the break with standard creatives. (Previously it setpmnd == pmxd == round(duration_ms), e.g.150100; GAM treats equal min/max as an exact-duration pod, which whole-second creatives like 15/30/60s cannot sum to — returning zero ads.) Pod params remain opt-in at theassembleBallysManifestlayer for non-pod tags. - Preload lead time increased to 8 seconds (PLAYG-257).
BreakScheduler.PRELOAD_AHEAD_SECONDSis now 8 in all three cores (TS, Kotlin, Swift) so the IMA VAST round-trip has enough runway before a break starts. BreakSchedulernow respectsonBreakApproachingcallback acceptance (PLAYG-257). The callback may returnfalseto decline a preload (e.g. when an ad is already playing). In that case the scheduler does not mark the break preloaded and will retry on the next tick. UpdatedDolbyAds(TS, Android, iOS) to returnfalsewhenisAdPlaying()is true. Added conformance fixturepreload-8sto lock the behavior.
Fixed
- SCTE-35 cue timing: compute break start from PDT + segment durations (PLAYG-250).
parseCueOuts(packages/scte35-bridge/src/parser.ts) paired a bare/attribute#EXT-X-CUE-OUTwith the nearest#EXT-X-PROGRAM-DATE-TIMEand used that PDT verbatim as the break start. But a PDT applies to the segment that follows it and often precedes many segments, so a cue several segments later started too early — and multiple cues sharing one PDT collapsed to a single id. The parser now runs a segment clock (segStartMs): a PDT sets it, each#EXTINFadvances it by the segment duration, and a#EXT-X-CUE-OUT's start is the clock at that point (PDT + intervening#EXTINFdurations), with the id derived from the computed start. On the Bally's demo this both places breaks at their true wallclock time and surfaces every distinct break (≈64) instead of a handful. Unit tests added for the accumulation and for two cues under one PDT getting distinct starts. - Bally's demo: retain the full DVR window of breaks; status bar cleanup (PLAYG-250/248). The client passed no
retentionMs, soScte35ManifestStore's 30-min default pruned all but ~3 of the DVR window's 70+ cues; the Bally's client now sets a 24h window so every detected break is listed. The customer-demo status bar drops the status pill +cd-statusbar-rightwrapper (status/diagnostics print to the browser console); the Dolby Optiview logo stays, rendered black on the white bar. The Bally's event log was moved from the page to the browser console, and the pause ad uses a self-hosted still (public/bally-pause.jpeg). - Bally's demo: black video, pre-roll no-fill, and broken pause image (PLAYG-250, epic PLAYG-246). Three defects found by driving the page in a real browser (Playwright, US VPN): (1) Black video — the Bally stream is HLS/TS, which THEOplayer transmuxes via a worker + helper
iframe.htmlloaded fromlibraryLocation; with a cross-origin CDN location (jsdelivr) that worker/iframe messaging stalls silently (segments download, nothing is ever appended to the MediaSource, no error). Fixed by self-hosting the THEOplayer library on our own origin (THEO_LIBRARY_LOCATION = '/theoplayer/'inpackages/demo/src/player-factory.js;packages/demo/vite.config.jsstages the installed build intopackages/demo/public/theoplayer/— gitignored — at startup so dev and the production build both serve it same-origin). (2) Pre-roll/mid-roll VAST returned no ad — thepmnd/pmxdad-pod-duration params were always appended, turning the request to Google's single-ad sample tag (the page default) into an ad-pod request it answers with no ads. Pod sizing is now opt-in (BallysManifestOptions.podDuration, default off) inpackages/demo/src/customer-demo/ballys-manifest.ts, so the default tag fills reliably. (3) Pause static image 403 — the newscaststudio.com Bally still is hotlink-protected (403 to any cross-origin request); replaced with a self-hosted Bally Sports still (packages/demo/public/bally-pause.jpeg, served at/bally-pause.jpeg) so it always loads. Also: the content player's THEOplayererror/waiting/canplay/playingevents are now surfaced (a failed load was previously silent); the Bally's page event log was removed from the UI and is printed to the browser console instead (kept in an in-memory ring buffer for the Xagget agent'sgetLog); and the page embeds a dormant Xagget device agent (packages/demo/src/customer-demo/xagget-agent.js) that only activates when opened with the Xagget bootstrap qu...erystring vars (?deviceId=&broker=), exposingstart/stop/seek/setConfig/getState/getLogfor on-device E2E without affecting normal use.
[0.32.0] - 2026-07-12
Removed
- Retire the legacy BrowserStack/Playwright E2E suite (PLAYG-213). Removed the entire
e2e/Playwright/BrowserStack package (e2e/web,e2e/mock-server,e2e/scenarios,e2e/shared,e2e/media,e2e/android,e2e/ios,e2e/scripts,e2e/browserstack.yml,e2e/browserstack.smoke.yml,e2e/playwright*.config.ts,e2e/package.json,e2e/tsconfig.json) and thedocs/browserstack-web-e2e.mdguide. Thee2e/folder is now only a local environment home: a rewrittene2e/.env.example(Xagget broker vars),e2e/.gitignore, ande2e/README.mdpointing to the Xagget docs. Rootpackage.jsonscriptse2e:*,test:e2e*, and related dependencies were dropped. The cross-platform E2E path is the Xagget harness inpackages/test-app. - Remove the customer-onboarding capability (PLAYG-216). Removed the
packages/onboardingpackage (demo generator, matrix runner, report, validation, templates, and tests), the.windsurf/workflows/onboard.mdworkflow, and thedocs/customer-onboarding.mdguide. All references inAGENTS.md,CONTRIBUTING.md,docs/jira-workflow.md,.windsurf/workflows/*.md,.devin/playbooks/develop.md, andpackages/demo/docs/17-install.mdwere updated to remove the customer-sweep/BrowserStack mentions. The unrelated developer-tutordolby-onboardingAI agent remains inpackages/sdk/ai/agents/dolby-onboarding/.
Added
- Bally's DVR customer demo (PLAYG-250, epic PLAYG-246). The first customer demo (
ballys.html+packages/demo/src/ballys.js, registered as a Vite input and listed in the portal). It plays Bally's live DVR HLS/TS stream (https://fast02.channels.ballys.tv/abr_default/index_dvr.m3u8) with THEOplayer (which transmuxes TS in the browser — itslibraryLocationmust match the running THEOplayer build). The ad-break manifest is generated entirely client-side, with no backend:packages/demo/src/customer-demo/scte35-client.tsfetches the stream's playlist (following one master → variant hop), parses its#EXT-X-CUE-OUT:DURATION=markers, and returns awallclockbreak manifest that is handed to the SDK via theinterceptManifestRequesthook (returning a body short-circuits the network). A mid-roll ad-format selector (single / double / L-Shape) fills every break with a VAST ad, appendingpmnd/pmxdad-pod-duration query parameters (min/max, milliseconds) sized to the break viapackages/demo/src/customer-demo/vast-pod.ts. To support this, the shared@dolby-ads/scte35-bridgeparser now also recognises the#EXT-X-CUE-OUT:DURATION=<n>attribute form (previously only the bare#EXT-X-CUE-OUT:<n>form), so streams that carry only the cue + duration (no SCTE-35 payload, noDATERANGE) are handled. The whole manifest is assembled client-side by the pure, unit-testedpackages/demo/src/customer-demo/ballys-manifest.ts, and the page lets you configure the VAST tag URL, toggle a pre-roll (single / double / L-Shape VAST, or a non-linear Bally's-logo image overlay), and toggle a static pause ad (the Bally Sports still image, or an MP4 video) shown when the viewer pauses. The page also lists the detected mid-roll breaks; clicking one seeks 5s before it (DVR seek-back, via the adapter'sprogramDateTime). Start mutes THEOplayer and autoplays the stream (so muted autoplay is never blocked — which also lets THEOplayer exposeEXT-X-PROGRAM-DATE-TIMEand clears the non-fatalDA-PDT-MISSINGwarning), and a basic control bar (play/pause, mute, DVR seek slider, time) sits under the player. The stream is US-only (needs a US VPN) and requires cross-origin playlist reads, so it cannot run on the automated E2E lab; coverage is unit tests for the parser, the client, the VAST helper, and the manifest assembly, plus manual verification. - Password-protected customer demo portal (PLAYG-249, epic PLAYG-246). New
portal.html(+packages/demo/src/portal.js, registered as a Vite input) is an overview page that lists every customer demo as a tile — the customer name in uppercase plus a short description — read from thecustomer-demo/registry.tsregistry, so a new customer appears automatically. It is protected by a soft, client-side password gate (packages/demo/src/customer-demo/portal-gate.ts, credentials optiview / bumba, remembered for the browser session via sessionStorage); the customer demo pages the tiles link to stay public. Because the demo is a static site this gate is not real authentication — a deployed portal should be protected with HTTP basic auth at the reverse proxy. Pure tile rendering (customer-demo/portal-view.ts) and the gate are unit-tested; no SDK/core/brain change. - Customer-demo shell + look-and-feel guidelines (PLAYG-248, epic PLAYG-246). Groundwork for a set of per-customer demo pages and a portal that lists them. New
packages/demo/src/customer-demo/shell.tsrenders the shared customer-demo chrome — a top status bar with the customer's logo/name on the left and the Dolby Optiview logo on the right — themed entirely through per-customer--brand-accent/--brand-radius/--brand-surfaceCSS custom properties (renderStatusBar,brandVars,mountCustomerShell,CUSTOMER_SHELL_CSS). Newpackages/demo/src/customer-demo/registry.tsis the single typed source of truth for customer demos (CustomerDemo/CustomerBrand,toTile/customerTiles), read by both the portal and the guidelines. A new guidelines page (packages/demo/docs/20-customer-demos.md) documents the status-bar contract, branding tokens, the client-side ad-break-manifest pattern (viainterceptManifestRequest), and how to add a new customer. Demo-app-only groundwork; no customer pages or portal are wired yet (follow-up stories) and no SDK/core/brain behavior changed. - E2E harness: DVR seek-back / forward-seek / resume-seek guard coverage (S15, PLAYG-235). A new
dvrfeature is added to the E2E matrix (runner/matrix.ts) with three CLI-selectablevariantcells (seekback-rearm,forward-no-replay,resume-seek-guard) and matching aliases inrunner/scenario-id.ts. The scenarios are VOD/DAR/Static/single (dvr-*-static-single-vod-dar) so theseekcommand can deterministically drive the playhead on the PTS timebase:dvr-seekback-rearmasserts a completed mid-roll re-arms and fires a second lifecycle when the viewer seeks back before the break;dvr-forward-no-replayasserts a future mid-roll is skipped and never plays after the viewer seeks forward past it;dvr-resume-seek-guardusesscheduleBreakwithresumeOffset: 0to force the SDK's internal post-break resume-seek and assertsBreakScheduler.notifyResumeSeekprevents the just-completed break from re-firing.MockBreakStorenow supportsresumeOffsetinScheduleBreakParams/ScheduledBreakSummaryandPlayerController.loadpinsMockBreakStore.setNow()so live break wallclock epochs are stable fordvrand other live scenarios. Docs (docs/e2e-test-plan.md) updated; unit tests added forrunner/matrix.ts,runner/scenario-id.ts, andscenarios.ts. - E2E runner CLI flags and root
npm run e2eshortcut (PLAYG-192). The Xagget E2E runner inpackages/test-app/src/runner/now accepts CLI flags as the primary selection mechanism. Selection flags (--features,--adapters,--platforms,--sources,--experiences,--content,--insertion,--variants) and run knobs (--timeout-ms,--device-retries,--report-json,--triage-json,--dist,--dry-run,--list,--help) take precedence over the matchingE2E_*env vars. Pure parsing logic is inpackages/test-app/src/runner/cli-args.ts(unit-tested) and consumed bypackages/test-app/src/runner/cli.ts. The rootpackage.jsonadds"e2e": "npm run e2e -w @dolby-ads/test-app --"sonpm run e2e -- --features preroll --adapters hlsjs --dry-runruns from the repo root.--helplists every flag and its valid values;--listis an alias for--dry-run. @dolby-ads/scte35-bridge+ DVR demo page (PLAYG-231). The demo's live and DVR streams are now external, publicly-hosted sources —https://discovery.theo.live/v2/distributions/demo/hls/main.m3u8(live, no breaks) andhttps://demo.unified-streaming.com/k8s/live/stable/scte35.isml/.m3u8(DVR, real SCTE-35 markers) — replacing the self-hostedlive-originpackage, which has been removed. The new@dolby-ads/scte35-bridgepackage polls the DVR stream's#EXT-X-DATERANGE/#EXT-X-CUE-OUTmarkers (falling back to the bare#EXT-X-CUE-OUT+ nearest#EXT-X-PROGRAM-DATE-TIMEpairing when noDATERANGEis present) and continuously serves the detected cues as a live,wallclock-timebase break manifest (GET /scte35-manifest/v1/:orgId/channels/:channelId), deployed alongside the other backend services (deploy/services/docker-compose.services.yml, routed by Traefik on a distinct/scte35-manifest/prefix so it coexists with@dolby-ads/break-manifest-server's/manifest/route). A dedicated DVR demo page (sidebar) plays the DVR stream, lists breaks polled live from the bridge, lets you click a break to seek 5s before it (demonstrating DVR seek-back re-fill), and offers a Static/VAST/GAM ad-format selector that rewrites each break's asset viainterceptManifestResponsebefore the SDK sees it. Tune-in is disabled on this page since the source stream's high ad duty cycle would otherwise trigger a join-in-progress break almost immediately.- E2E harness: negative-scenario error injection (S13b, PLAYG-236).
MockBreakStore/scheduleBreakgain aforceError: 'http' | 'vast'param that swaps the primary asset's URI for a deliberately broken one — a guaranteed-404 static URL, or Google's own IMA sample-tag error variant (sample_ct=linear_error) for VAST — so the SDK's error-handling path can be exercised end-to-end on a real device instead of only via mocked-unit tests. Three fixed-name device scenarios cover the matrix's three negative-source cells:neg-static-http-error(a 404'd Static asset degrades to a balancedadbreakbegin→aderror→adend→adbreakendlifecycle withDA-AD-PLAYBACK-ERROR),neg-gam-failure(loading with an invalid GAMcustomAssetKeyfails the DAI session withDA-GAM-SESSION-FAILEDand content plays through with GAM breaks skipped, no break ever attempted), andneg-vast-error(the broken VAST tag brackets cleanly with anaderrorand a VAST error diagnostic).candidateScenarioIdsaliases the matrix'snegativefeature cells to these fixed names. Harness-only — no SDK/core/brain change. - E2E harness: Live tune-in + chaining coverage (S14, PLAYG-243), and CLI parity for every fixed-name coverage story.
scheduleBreak'soffsetSecondsnow accepts negative values, scheduling a Live mid-roll that already started in the past (join-in-progress);loadgains atuneIn: { enabled?, minBreakDurationSeconds? }override threaded intoDolbyAdsConfig.tuneIn, and theadbreakbeginevent'stuneIn: { elapsedSec, remainingSec }detail (previously dropped) is now recorded on the app's event timeline. Four fixed-name device scenarios exercise scheduler behaviors that had only core unit coverage:tunein-live-remainder-static-single-live-dar(joins ~10s into a 20s break; asserts the full lifecycle, atuneIndetail onadbreakbegin, presentation for the remainder rather than the full duration, and content resume),tunein-live-under-min-static-single-live-dar(same in-progress break, buttuneIn.minBreakDurationSecondsraised to 30s; asserts the break is skipped entirely — no ad-break events, no error, content plays), plus, closing pre-existing gaps found while auditing the whole suite for CLI parity: newlive-behaviors(variant-axis fan-out:tunein-remainder/tunein-under-min/chained-adjacent/chained-gap),break-cut,midroll-double, andpause-variants(variant-axis:duration-cap/video-asset) features inrunner/matrix.tsand their aliases inrunner/scenario-id.ts, plus a newE2E_VARIANTSselection env var — so every fixed-name coverage scenario in the suite (previously only reachable via the manual MCP loop) is now selectable throughnpm run e2e/E2E_FEATURESlike every other cell.chained-live-adjacent-static-single-live-darandchained-live-gap-static-single-live-dar(two mid-rolls with a 0s or 1s gap; asserts both lifecycles fire in order with no contentplayingevent between them — the overlay is held across the chained transition). Harness-only — no SDK/core/brain change.
Fixed
- Mid-roll DAI (insertion) breaks no longer re-fire after they end (PLAYG-241). For a DAI break on the PTS (VOD) timebase that paused content (
single/lshape_adformats), the runtime seeks content back to the break cue when the break ends. Because a break triggers on the first poll a fraction of a second past its cue, that return-to-cue was a small backward step that the DVR seek-back re-arm logic (rearmOnRewind) mistook for a user seek, re-arming and re-firing the just-completed break — emitting a secondadbreakbegin/adbreakendand leaving the break "active". TheBreakSchedulergainsnotifyResumeSeek(target, breakEnd), which the runtime (AdPlayerController.applyResumeSeek→ newAdPlayerCallbacks.onContentResumeSeek→DolbyAds) calls before the resume-seek so the scheduler treats the backward step as internal and does not re-arm; a genuine user seek back below the cue still re-fills the break exactly as before. Implemented identically across all three cores — TS (@dolby-ads/core), Kotlin (android/dolbyads-core), Swift (ios/DolbyAdsCore) — and locked by a new conformance fixture (dai-resume-seek-no-refire) plus TS scheduler unit tests. DAR,double/overlay/lshape_content, pre-roll, and the existing DVR seek-back re-fill (dvr-seekback-refill) behaviour are unchanged. - VAST/CSAI ad video now fills its box (PLAYG-242). When a client-side VAST ad (Google IMA CSAI) played in a format that gives the ad its own box —
single,double, orlshape_ad— the ad video rendered smaller than the correctly-sized box drawn for it. The VAST path inAdPlayerController.playVastAssethanded the ad<video>element toVastAdManagerwithout any sizing CSS, soVastAdManager.slotWidth()/slotHeight()read the element's defaultclientWidth/clientHeight(or the 640×360 fallback) and passed those undersized dimensions to IMA'sAdsRequest/AdsManager.init(...). The VAST path now re-stamps the element withwidth:100%;height:100%;object-fit:containbefore IMA reads it — the same stamp the static/vendor-media path already applies — so IMA renders the ad at the real box size. Google DAI (GAM) and static/vendor-media playout were already correct and are unchanged.
Changed
- Workflow: executor-grade plans mandated for Standard/Heavy effort. The
/developworkflow (AGENTS.md,.windsurf/workflows/develop.md,.devin/playbooks/develop.md,docs/bumba-communication-style.md) now requires Standard/Heavy-effort plans to be executor-grade: a§0 Executor guidelinessection, numbered phases with exact commands and verification gates, the usual acceptance criteria/effort/recommended-model sections, and an explicit STOP point before merge/release/deploy. Light-effort plans are unaffected. Documentation-only change; no SDK/core/brain behavior changed.
[0.31.0] - 2026-07-12
Added
- Break manifest server: idle TTL cleanup (PLAYG-233). Stored break manifests in
@dolby-ads/break-manifest-servernow expire 2 hours after they were last fetched viaGET /manifest/v1/:orgId/channels/:channelId.POSTseeds the clock at creation, and every channel poll refreshes it, so continuously used channels stay alive while abandoned ones are removed. A background sweep runs every 60 seconds, andGET/listalso evict expired entries lazily. TTL and sweep interval are configurable when embeddingManifestStoreorcreateBreakManifestApp.
[0.30.0] - 2026-07-12
Changed
- Default live stream and Optiview Ads domain updated (PLAYG-229). The demo page's default Content URL/Stitcher Origin URL, and every E2E harness's default live HLS fallback (web, Android, iOS), now point at the OptiView demo distribution (
https://discovery.theo.live/v2/distributions/demo/hls/main.m3u8) instead of a specific team distribution. Separately, the SDK's defaultmanifestBaseUrl—DEFAULT_MANIFEST_BASE_URL(TS@dolby-ads/core),DEFAULT_MANIFEST_BASE_URL(Kotlinandroid/dolbyads-sdk),defaultManifestBaseUrl(Swiftios/DolbyAdsSDK) — now points athttps://optiview-ads.cdn.sneezysparrow.com/manifest/v1(washttps://optiview-ads.sneezysparrow.com/manifest/v1), following the Optiview Ads backend's domain move to a CDN subdomain. Integrators who pass an explicitmanifestBaseUrlare unaffected; only the built-in default changed.
[0.29.0] - 2026-07-12
Added
- Wallclock breaks now match the stream Program Date Time, enabling DVR seek-back re-fill (PLAYG-221). For the
wallclocktimebase theBreakSchedulerpreviously matched a break's ISOstartagainst the real system clock, which (a) diverged from the manifest spec and thePlayerAdaptercontract — both say wallclock breaks match onEXT-X-PROGRAM-DATE-TIME— and (b) made seeking backward in a live DVR window never re-fill a past break, since real time only advances. The scheduler now reads the player'sprogramDateTimefor the wallclock timebase (e.g. THEOplayer'splayer.currentProgramDateTime, already surfaced by every adapter), falling back to the injected clock with a one-shotDA-PDT-MISSINGdiagnostic when the stream exposes no PDT (PLAYG-223). Break retirement is now position-relative: when the playhead moves back into or before a completed/skipped break's window, that break re-arms and re-fills; scrubbing forward past it re-suppresses it; the in-progress break never double-fires (PLAYG-224). This also applies to theptstimebase (backward VOD seeks). Pre-rolls are session-relative and are never re-armed. Implemented identically across all three cores — TS (@dolby-ads/core), Kotlin (android/dolbyads-core), Swift (ios/DolbyAdsCore) — and locked by new conformance fixtures (wallclock-pdt-match,dvr-seekback-refill,dvr-seekforward-suppress) plus per-core unit tests (PLAYG-225/226/227). The conformance harness gained a per-timeline-stepprogramDateTimeto drive PDT (and backward seeks) deterministically. On-device THEOplayer seek-back E2E coverage is deferred — it needs a mid-scenario seek action the current harness lacks.
Fixed
Core:
AdPlayerController.destroy()now disposes the factory-created ad player (PLAYG-220). When the SDK built the ad player viaadPlayerFactory(e.g. a THEOplayerChromelessPlayeradapter),destroy()removed the overlay element and cleared references but never called the adapter's owndestroy(), leaking one live player instance — with its media pipeline and workers — per SDK session. On a shared E2E device session running many consecutive scenarios this accumulated leaked THEO players.destroy()now awaitsadPlayer.destroy()(errors logged, teardown never blocked); the harness (packages/test-app/src/adapters.ts) also awaits THEOplayer's asyncdestroy()for both content and ad players. Unit-tested. Portable-brain note: web-runtime-only (AdPlayerControlleris the web overlay runtime); no conformance impact.E2E harness: hung on-device commands now self-diagnose instead of timing out opaquely (PLAYG-220). Scenario steps drove
load/playcommands unbounded — if a command hung (as in the THEO deadlock above), the wholerun_scenariohit the transport cap with zero context. NewinvokeBounded()(packages/test-app/src/scenarios.ts) races each command against acommandTimeoutMswatchdog (default 30 s, overridable per run via params) and fails with a report naming the hung command plus a full state snapshot (phase, stream events, diagnostics timeline). Also addse2e/scripts/probe-theo-repeat.mjs, a local Playwright probe that replays consecutive shared-session scenario cycles in one page. Unit-tested. Harness-only — no SDK/core/brain change.E2E harness: THEO content load no longer deadlocks under a covering pre-roll (PLAYG-220). The test-app's
loadContentTHEO path resolved only on theplayingevent. With an immediate covering pre-roll scheduled, the SDK's content hold (DA-PREROLL-CONTENT-HELD) pauses the player at session start and re-pauses on everyplaying, so the event may never fire — theloadcommand hung to the run cap (thesingle/lshape_adshare of the THEO failures).loadContentnow resolves on the first ofcanplay/playingand rejects onerror. Also adds a dev-mode debug hook (window.__testApp+ periodic state dumps when no Xagget bootstrap is present) and a local Playwright probe (e2e/scripts/probe-theo-preroll.mjs) used to verify both PLAYG-220 fixes without a lab device. Harness-only — no SDK/core/brain change.THEO
libraryLocationcan no longer drift from the bundled player (PLAYG-220). THEOplayer resolves its runtime workers (e.g. the HLS/TS transmuxerTHEOplayer.transmux.*) fromlibraryLocationat play time, and the version there must match the bundled player. The test-app hardcodedtheoplayer@11.4.0on the CDN URL while depending on^11.4.0— any dependency bump would silently break HLS/TS playback. The test-app now derives the URL from the player's own exportedversion(packages/test-app/src/adapters.ts). The demo serves the library self-hosted on its own origin (packages/demo/src/player-factory.js,THEO_LIBRARY_LOCATION = '/theoplayer/'), copied from the installed package, so it always matches the bundled version by construction (and avoids the cross-origin worker/iframe stall a CDN location caused on TS/HLS streams).THEOplayer adapter: progressive MP4/WebM sources no longer hang (PLAYG-220).
THEOplayerAdapter.load()hardcodedtype: 'application/x-mpegurl'for every URL, so a progressive MP4 creative (the static/VAST linear ad) was parsed as an HLS playlist — playback never started and the ad path hung until the scenario timeout (every linear-ad experience under THEO; backdrop-onlylshape_contentwas unaffected). The source type is now inferred from the URL extension viainferSourceType()(.mp4/.m4v→video/mp4,.webm→video/webm); everything else — HLS playlists and extensionless GAM pod URLs — keeps the HLS type, preserving the original GAM behaviour.E2E harness:
lshape_contentbackdrop no longer renders a broken image (PLAYG-207). The SDK renders thelshape_contentbackdrop by settingassets[0]as an<img src>, so the asset must be a static image. The static mock supplied the primary video (SAMPLE_AD_MP4) asassets[0], producing<img src="…orange-aid-pause.mp4">— a broken-image icon behind the content pip.buildVariantnow supplies a staticIMAGEbackdrop (SAMPLE_COMPANION_IMG) forlshape_content, matching the SDK's<img>render path. On top of that,SAMPLE_COMPANION_IMGitself (the Google-hosted sample PNG also used fordouble/lshape_adcompanions) turned out to 404, still yielding a broken-image icon — it is now an inline SVG data URI ("Dolby Ads sample creative"), so mock image creatives render with zero network dependency. Harness-only — no SDK/core/brain change. (The underlying SDK gap —renderLshapeContentBackdropassumes an image but the spec/fixtures allow a video-typed backdrop — is tracked separately.)E2E harness: isolate players between scenarios on a shared device session (PLAYG-207).
PlayerController.load()disposed the previous player with fire-and-forgetvoid destroyPlayer(...), so the nextloadbuilt a newshaka.Playeron the same<video>element before the old one detached. Shaka forbids two players on one media element, so every other shaka scenario on a shared session hung to the 120 s cap — a perfectly alternating pass/timeout across the preroll sweep (the timeout let the priordestroy()finish, so the following cell recovered).teardownSdk()is nowasyncandload()awaits it before building the next player, so consecutive shaka scenarios are fully isolated. Verified on-device: five consecutive shaka cells that previously errored at even positions now all pass (~12–15 s each). Harness-only — no SDK/core/brain change.E2E harness:
lshape_contentscenario asserts the correct backdrop-only lifecycle (PLAYG-207).lshape_contentshows branded content for the whole break and plays no individual ad, so it emits onlyadbreakbegin → adbreakend(noadbegin/adend).defineBreakLifecycleScenariopreviously asserted the full ad lifecycle for every experience and so wrongly failed alllshape_contentcells. It now derives the expected event subsequence per experience via the newexpectedLifecycle()(packages/test-app/src/scenarios.ts) — backdrop-only forlshape_content, full lifecycle otherwise — and the assertion label reflects the expected sequence. Verified passing live on hls.js and shaka. Harness-only — no SDK/core/brain change.Diagnostics: an unknown diagnostic code no longer throws (PLAYG-218).
DolbyAds.diagnose()readDIAGNOSTIC_CODES[code].level/.categorywithout checking the code exists, so calling it with an unrecognised code threwTypeError: Cannot read properties of undefined (reading 'level'). On the ad-playback path that TypeError was caught and surfaced as a spuriousaderrorwith that confusing message.diagnose()now falls back to levelinfo/ categoryunknownfor unrecognised codes, so an unknown code is still emitted (never lost) and never throws. Found while investigating PLAYG-218 (shaka + overlay pre-roll "hang"): that reported hang no longer reproduces on currentmain— the exactpreroll-static-overlay-vod-darcell runs the fulladbreakbegin → adbegin → adend → adbreakendlifecycle with content resume on Mac/Chrome, Mac/Safari, and Win/Edge (verified on-device via Xagget) — it was already fixed by PLAYG-215/PLAYG-217; only this latent robustness gap remained.
Changed
- E2E: default test content is now self-hosted on the demo server (PLAYG-220). The default VOD (
DEFAULT_CONTENT_URL) moved from Unified Streaming's public Tears of Steel demo — which began returning 503s under repeated fetches, killing every third scenario on a shared device session — to a 5-minute synthetic CMAF/fMP4 HLS asset on our own infra (https://ads-sdk.xnappet.live/test-content/vod/main.m3u8, generated at deploy time by the newscripts/build-test-content.sh,/deploystep 1e). The default live stream (DEFAULT_LIVE_CONTENT_URL) likewise moved from the THEOlive demo distribution to an always-on synthetic live HLS produced by the newlive-contentffmpeg service (deploy/services/docker-compose.services.yml) writing straight into the demo webroot (/test-content/live/main.m3u8). Both use atestsrcmoving-clock pattern (yuv420p) and are served withAccess-Control-Allow-Origin: *(nginx/test-content/block on the server). Also bumps@xagget/device-sdk0.7.2 → 0.14.0 to match the upgraded lab stack. Internal tooling — no SDK/core/brain change. - E2E runner: one browser per lab host (PLAYG-207).
runMatrix()now groups platforms byinstallerIdand runs the platforms that share a host serially (lab hosts still run in parallel), so a single machine never drives two browsers at once — Chrome + Safari share the Mac host, Edge + Firefox share the Windows host. This removes the parallel-contention timeouts seen when four platforms hit two hosts at once.packages/test-app/src/runner/orchestrator.ts; docs updated (docs/e2e-test-plan.md,docs/e2e-device-runs.md). Internal tooling — no SDK/core/brain change. - E2E runner: retry device allocation only, never scenario results (PLAYG-207). Retries now target infrastructure flake:
requestDeviceis retried up toE2E_DEVICE_RETRIEStimes (default 3) before a platform's cells are markederrored. A scenario result — pass, assertion failure, or a scenario timeout (e.g. a hung on-device command such as the shaka + overlay hang, PLAYG-218) — is a real signal and is reported as-is on the first attempt. Previously the runner retried scenario timeouts, which masked genuine hangs and wasted ~`E2E_TIMEOUT_MSper extra attempt. Renamed the knobE2E_TIMEOUT_RETRIES→E2E_DEVICE_RETRIESand reworkedrunMatrix()/runPlatform()(packages/test-app/src/runner/orchestrator.ts); docs updated (docs/e2e-device-runs.md,docs/e2e-test-plan.md`). Internal tooling — no SDK/core/brain change. - E2E: test-app default content stream is now CMAF/fMP4, not MPEG-TS (PLAYG-218).
DEFAULT_CONTENT_URLin@dolby-ads/test-appswitched from the MUXx36xhzzTS stream to Unified Streaming's Tears of Steel (fragmented-MP4 / CMAF), so the Shaka content adapter plays it natively without transmuxing and the default smoke content is more representative. Internal tooling — no SDK/core/brain change.
[0.28.0] - 2026-07-01
Fixed
- Break cut short now emits a balanced
adendfor the in-flight ad (PLAYG-215). When a break ended because its max-duration / break-cut timer elapsed while an ad was still playing (a creative longer than its break), the SDK hard-cut back to content and emittedadbreakendwithout a precedingadend— an unbalancedadbegin(adbreakbegin → adbegin → adbreakend). This was a portable-brain bug inAdBreakSequencer: thebreak-elapsed(andbackdrop-elapsed) outcome finalized with an empty prefix, dropping the in-flight asset'sadend. The sequencer now tracks whether an asset is in-flight (adbeginemitted, not yet settled) and, on a cut, emits that asset'sadendbeforeadbreakend— fixed identically across all three cores (TS/Kotlin/Swift) and locked by a new conformance fixture (sdk-overlay-break-cut). A break cut before any ad begins still emits noadend; thelshape_contentbackdrop cut still emits no per-ad events. On web, the overlay break-cut timer now routes through the sequencer'sbreak-elapsedoutcome instead of callingendBreak()directly (guarded likeonAdEndedso it cannot double-emit against a natural end or the PLAYG-217 GAM pod-end). The native runtimes already route the cut throughBREAK_ELAPSED, so the sequencer fix flows through. Balances the sibling GAM-pod fix (PLAYG-217); the two cover different cut paths. - GAM pod serving: forward timed metadata to IMA + balanced
adbegin/adend(PLAYG-217). In the SGAI / GAM client-side pod-serving route (avendor: 'gam'asset resolved to a stitched pod URL and played through the SDK's ad player, pre-roll & mid-roll), the SDK never forwarded the ad player's in-stream timed metadata (ID3) to IMA. As a result IMA fired neither its ad-tracking beacons (impressions/quartiles — monetization was broken) nor its ad lifecycle, so the served ad emittedadbeginwithout a matchingadend(adbreakbegin → adbegin → adbreakend). The ad player'stimedmetadatais now forwarded toGamStreamManager.processTimedMetadata(webAdPlayerController, AndroidOverlayAdRenderer; iOS already auto-reads ID3 viaIMAAVPlayerVideoDisplay), and IMA's pod-levelAD_BREAK_ENDEDdrives the publicadend— yielding the balancedadbreakbegin → adbegin → adend → adbreakendlifecycle across all three cores (oneadbegin/adendper pod asset; multi-ad pods are not cut short). The SSAI (SsaiController) and shared-element GAM paths were already balanced and are unchanged. Web is unit-tested (AdPlayerController.gam.test.ts); the native IMA-DAI eventing is verified by code parity + on-device (no headless IMA), tracked inconformance/PENDING-PARITY.md. Related: PLAYG-215 (break-cut duration cutoff also dropsadend).
Changed
- E2E: bump
@xagget/device-sdk0.7.0 → 0.7.2 (PLAYG-203). Picks up the transport's runtime-aware broker defaults (XAG-64) and a more robustmqttconnectresolution. No public-API changes. Two test-app adjustments: (1) the Vitemqtt→ESM alias is still required — even with 0.7.2's broaderconnectprobing, Vite resolves baremqttto mqtt@5's browser-UMD build whose interop hides a usableconnect, so the agent fails to start without the alias; (2) theplaycommand now swallows the benignplay()-interrupted-by-pause()AbortError(a pre-roll pauses content immediately after starting it) viaisPlayInterruptedByPause, re-throwing all other errors — this race is timing-sensitive and only surfaced on some lab machines. Validated green on real Chrome (office-mac-miro). Internal tooling — no SDK/core/brain change.
Added
- E2E: preroll coverage across applicable cells (PLAYG-207, S8 of PLAYG-192). The device app now advertises a scenario for every applicable preroll cell instead of just the S4 seed.
registerScenarios(packages/test-app/src/scenarios.ts) enumerates the preroll set from the sharedresolveCells({ feature: ['preroll'] })matrix and registers onepreroll-<source>-<experience>-<contentType>-<insertion>per cell (Static/VAST/GAM × allowed experiences × VOD-DAI/VOD-DAR/Live-DAR), so the S6 runner resolves and runs them instead of reportingscenario-not-registered. The break-lifecycle body is now adapter-param-aware — the adapter is the one axis not in the scenario name, so it is read from the runner'srun_scenarioparams (ctx.params.adapter), letting cells that differ only by adapter share one on-device scenario. NewprerollBinding()threads a live HLS source for Live-DAR cells (DEFAULT_LIVE_CONTENT_URL= the THEOlive demo distribution) and GAM credentials for online-to-IMA cells (demonetworkCode/customAssetKeyfrommock-manifest.ts); both, plus the VOD URL, are overridable viaRegisterScenarioOpts. The S4 seed aliasbreak-lifecycle-static-single-vod-daris preserved. Unit-tested (param-aware adapter override + fallback, GAM credential threading, live-URL selection, and registration of every applicable preroll cell name). Three test-app harness bugs found via live Mac/Chrome runs were fixed: (1)PlayerController.load()now resets the per-stream event/diagnostic timelines so a scenario running after another on the same device session can't pass on a prior run's staleadbreakend; (2)index.htmlnow loads the Google IMA SDKs (ima3.jsbeforeima3_dai.js) — without them VAST + GAM broke withaderror; (3) the GAM mockvendorParametersnow carry the full EABN pod shape (type:'pod'+eabnVersion) soisGamVendorParametersaccepts them (else the pod resolved to no URI). Scenario failure messages now include theaderrordetail + diagnostic codes for triage. Validated green onhlsjs · MAC/Chrome:Static · single× {VOD-DAR, VOD-DAI, Live-DAR} andVAST · single · VOD-DAR. GAM preroll is bug-filed PLAYG-217 — DAI engages and plays a real ad but emitsadbeginwithoutadend(report-only; not fixed here). Internal tooling — no SDK/core/brain change. - E2E: bug triage + dedup capability for runs (PLAYG-206, S7 of PLAYG-192). The runner now turns a run's failures into deduped Jira bug candidates (report-only).
triageFromSummary()(packages/test-app/src/runner/triage.ts) groups failed/errored cells by a binding-only signature —<diagnostic-code>|<feature>|<adapter>|<platform>(code = firstDA-*token in the message, elseUNKNOWN) — so repeated failures collapse into one candidate with N occurrences, and emits the exact Jira Bugtitle/body(body carries anE2E-SIGNATUREmarker for cross-run dedup) plus a ready-to-runsearchJql.npm run e2eprints a Triage block after the roll-up and writes candidates toE2E_TRIAGE_JSONwhen set. Because there is no Jira client in-repo, the search-before-file (occurrence comment vs newPLAYGBug) andXAG[xagget]routing for framework defects are the documented agent step in the newdocs/e2e-bug-triage.mdhow-to (linked from/run-e2eStep 6,.devin/playbooks/run-e2e.md,docs/e2e-test-plan.md,docs/e2e-device-runs.md, andAGENTS.md). Unit-tested (diagnostic extraction, signature stability, within-run dedup, distinct-signature separation, pass/skip exclusion, title/body, JQL). Internal tooling — report-only, no SDK/core/brain change (runs never fix code). - E2E:
/run-e2edrives Xagget — selection + per-platform parallelism (PLAYG-205, S6 of PLAYG-192). A test-side runner under@dolby-ads/test-app(src/runner/) turns a selection into the applicability-pruned matrix and drives it on real lab hosts.resolveCells(selection)(runner/matrix.ts) applies the test-plan rules (native→MAC/Safari, DAI=VOD-only / Live=DAR-only, GAM→single, VAST→linear, negative→representative subset);runMatrix()(runner/orchestrator.ts) publishes the bundle once then runs each platform in parallel with one device session each, cells serial within a platform, aggregating everyTestReportinto a pass/fail roll-up (runner/report.ts). The live@xagget/publish+@xagget/runner-sdkdriver (runner/xagget-driver.ts) is loaded lazily from optional deps, so build/test/typecheck pass without them. Newnpm run e2e -w @dolby-ads/test-app(CLI readsE2E_FEATURES/E2E_ADAPTERS/E2E_PLATFORMS/… plusE2E_DRY_RUN,E2E_TIMEOUT_MS,E2E_REPORT_JSON). The/run-e2eworkflow is repurposed from the mock-backend/Playwright flow to drive this runner interactively, the/run-testsE2E section now points at it, and a new.devin/playbooks/run-e2e.mdmirrors it for Devin. Docs:docs/e2e-test-plan.md(selection/parallelism now reference the runner),docs/e2e-device-runs.md(thenpm run e2eloop), and the test-app README. Unit-tested (resolver applicability/selection, report aggregation, orchestrator parallel fan-out / skip / device-unavailable with a fake driver). Internal tooling — no SDK/core/brain change. The/run-e2e-browserstackworkflow is removed here and its references redirected (develop.md,onboard.md,jira-workflow.md,browserstack-web-e2e.md,onboarding/README.md,e2e/README.md); onboarding's customer-sweep entry point is re-homed in PLAYG-216, and the remaining BrowserStack files/deps + Playwright-spec retirement stay in S14 (PLAYG-213). - E2E: desktop-web test-plan matrix (PLAYG-204, S5 of PLAYG-192). New
docs/e2e-test-plan.md— the source-of-truth for what the Xagget desktop-web suite tests and what is green. Enumerates the platform × adapter × feature × source × experience × insertion × content matrix with applicability rules (native → MAC/Safari only; GAM →singleonly, online to Google IMA DAI; Static/VAST → offline), the runnable 13 engine cells (adapter × platform) and 9 source × experience combinations, selection semantics for subset runs, the per-platform-parallel / 1-session-per-platform model, the bug-dedup policy (signature = diagnostic-code + feature + adapter + platform; PLAYG vs XAG routing), and per-cell status tables per feature (legend not-covered / green / bug-filed; seeded with the S4 cell green). Midroll tracks two distinct scenarios:midroll-singleandmidroll-double-consecutive(two back-to-back mid-rolls, asserting both fire in order with a clean content segment between and each break balanced). Per review: Live-DAI is excluded (server-stitched DAI on a live edge builds latency — Live is DAR-only), pre-roll and pause-ads are covered on Live (immediate, no-delay pre-roll), and a Negative feature is added (neg-static-http-error,neg-gam-failure,neg-vast-error— assert graceful degradation, correct error diagnostic, and content continuity). Linked fromAGENTS.md,docs/e2e-xagget.md,docs/e2e-device-runs.md, and the/run-e2eworkflow. Docs-only — no code/SDK change. - E2E: first on-device Xagget scenario (PLAYG-203, S4 of PLAYG-192).
packages/test-appnow registers an on-device scenario (src/scenarios.ts) via the device agent'sregisterScenario. The parameterizedbreak-lifecycle-static-single-vod-darsmoke (hls.js · Static · single · DAR · VOD pre-roll) drives the app through its own commands (scheduleBreak→load→play) and asserts the full ad-break lifecycle adbreakbegin → adbegin → adend → adbreakend plus content resume, observed via thegetStateevent timeline (so it depends only onctx.invokeand is robust to the device SDK's async event surface).defineBreakLifecycleScenario(binding, timing)is platform/binding-agnostic for reuse by later coverage stories. Validated live on real Chrome via the Xagget MAC installer (publish_build→request_device→run_scenario):TestReport.status: passed. Three test-app fixes were needed to get the on-device run green: (1) Vite now aliasesmqttto its ESM browser build (dist/mqtt.esm.js) so the device SDK'srequire("mqtt")resolvesconnectin the bundle; (2) the hls.js ad-adapter factory uses the SDK'sRoutingAdAdapterso a progressive-MP4 ad creative routes to native<video>instead of failing in hls.js; (3) the Static creative is a ~12svideo/mp4(shorter than the 15s break) so the ad ends naturally — a creative longer than the break is currently cut off without anadend(unbalancedadbegin), tracked as PLAYG-215 (brain bug, to be fixed across all cores). Internal tooling — no SDK/core/brain change. Docs:packages/test-app/README.md,docs/e2e-xagget.md. - E2E: in-app mock-break fixture catalog (PLAYG-202, S3 of PLAYG-192).
packages/test-appnow ships a reusable fixture layer (src/fixtures.ts) that drives ad breaks entirely through the SDK manifest-interception API (interceptManifestRequestshort-circuit) with no Optiview backend. It enumerates the source × experience × content-type × insertion-type matrix —static/vast/gam×single/double/lshape_ad/lshape_content/overlay× VOD / Live × DAI / DAR (VAST limited to linear formats, GAM to a single pod) — and every cell builds aBreakManifestthat is validated by the SDK's ownparseBreakManifest, so a malformed fixture fails at author time. VOD uses the PTS timebase and Live uses wallclock (absolute ISO break starts). GAM fixtures emit avendor:gam/podasset with a client-side randomized podId and the demo network code / custom asset key, flaggedonlineToIma(Optiview-free but online to Google IMA DAI for ad-fill). ThescheduleBreakcommand now accepts acontentType(vod|live) and produces the same bodies on demand. Internal tooling — no SDK/core/brain change. Docs:packages/test-app/README.md,docs/e2e-xagget.md. - E2E: Xagget device app
@dolby-ads/test-app(PLAYG-201, S2 of PLAYG-192). A new private package (packages/test-app) that is the app under test for the Xagget-driven desktop-web E2E suite. It embeds the Dolby Ads SDK plus the Xagget device SDK (@xagget/device-sdk): reads the injected?deviceId=&broker=viaresolveBootstrap(), advertisesdeviceOnlineonstart(), and registers JSON-Schema'd driving commands —load,play,pause,seek,scheduleBreak,getState. Wires all four content adapters (hlsjs, shaka, THEOplayer, native) reusing the demo's player-factory approach and THEOplayer license. Ad breaks are mocked in-app via the SDK'sinterceptManifestRequesthook (no Optiview backend) forstatic/vast/gamsources (GAM uses a client-side randomized pod id). An on-screen diagnostics overlay renders SDK phase, binding, scheduled/active break, last error, and the event + diagnostic timelines. Builds to a web bundle with relative asset paths (base: './') for the Xagget manager store. Internal tooling — no SDK/core/brain change. Docs:packages/test-app/README.md,docs/e2e-xagget.md.
[0.27.0] - 2026-06-27
Added
- Manifest request interception (PLAYG-191). A new optional SDK config hook,
interceptManifestRequest, lets integrators inspect and modify the ad-break manifest HTTP request before it goes out — rewrite the URL, add request headers, or short-circuit the network with a mocked raw body (no request is made). The SDK invokes it on the initial fetch and every poll, before the network call. A returned mock body goes through the normal parse + validation path (raw body → validated; an invalid mock still fails), and the existinginterceptManifestResponsestill runs afterward. A throw is non-fatal: the SDK emits the newDA-MANIFEST-REQUEST-INTERCEPT-FAILEDdiagnostic and falls back to the normal network fetch of the original URL. The hook is primarily a testing aid (mock/redirect without a proxy) but is fully documented and tested. Implemented across all three cores in lockstep — web (@dolby-ads/coreManifestService), Android (HttpManifestSource+ManifestFetcherheaders,suspend), iOS (URLSessionManifestSource,async throws) — where the native cores forward the config hook to the injectedManifestSource. Locked by the cross-languagemanifest-request-interceptionconformance fixture (mock body → parsed) passing on all three cores. Docs: configuration, manifest (one Manifest interception section now covering both request and response), and diagnostics pages +README.
[0.26.0] - 2026-06-27
Added
- Manifest response interception (PLAYG-181). A new optional SDK config hook,
interceptManifestResponse, lets integrators inspect and modify the ad-break manifest on the client — inject a pre-roll, drop/rewrite a break, layer in client-side targeting — without a proxy. The SDK invokes it after fetch + validation, on the initial fetch and every poll, handing it the typed, parsedBreakManifest(never raw JSON; an invalid manifest fails validation before the hook runs). Its return value is used as-is (no re-validation); it may be async. A throw is non-fatal: the SDK emits the newDA-MANIFEST-INTERCEPT-FAILEDdiagnostic and falls back to the un-modified manifest. Implemented across all three cores with identical behaviour — web (@dolby-ads/coreManifestService), Android (HttpManifestSource,suspend), iOS (URLSessionManifestSource,async throws) — where the native cores forward the config hook to the injectedManifestSource(default sources apply it after parsing). Locked by the cross-languagemanifest-interceptionconformance fixture (parse → intercept → use) passing on all three cores. Demo: the Player page's Pre-roll feature now uses this hook instead of the oldwindow.fetchmonkey-patch (retiredpreroll-interceptor.ts;injectPreRollnow operates on the parsed manifest). Docs: configuration, manifest, and diagnostics pages + top-leveldocs/andREADME. - Ad format on break & ad events (PLAYG-182). Every break/ad-scoped SDK event now carries a
formatfield — the declared format of the break's selected variant (single,double,lshape_ad,lshape_content,overlay) — so consumers can branch on the ad format without re-reading the manifest variant. Added across all three cores in lockstep: Web (@dolby-ads/core—AdBreakBeginEvent/AdBreakEndEvent/AdBeginEvent/AdEndEvent/AdErrorEvent/the quartiles/AdTimeupdateEvent, plus a new exportedresolveBreakFormat(break)helper andformaton the portableAdBreakSequencerstep), Android (event.format: BreakFormat?), and iOS (event.format, plus theformatenum accessor). The value is the declared manifest format — a shared-element single-fullscreen downgrade still reports the manifest's format. It is absent only on content-sourcedaderror/waiting/playing(no break). The conformance harness pinsformaton everysdkSequencestep (newsdk-overlay-lshape-adandsdk-overlay-formatfixtures; all three cores pass), and the demo event log shows the format. Additive only — no change to event order, timing, scheduling, or rendering. - Demo: Preset Builder page (PLAYG-142). A new Preset Builder page (sidebar → Preset Builder,
/preset-builder.html) lets you visually assemble one or more Player-page presets and live-generates the shareable, URL-safe?presets=link plus the raw JSON, with Copy buttons and Open in Player. You can import an existing?presetsURL or base64 token to edit it (full round-trip), add/remove/reorder presets, and an inline usage guide explains the feature. Builds on PLAYG-115: addsencodeUrlPresetsSafe()(base64url) topackages/demo/src/demo-presets.tsand a new unit-tested pure helperpackages/demo/src/preset-builder-model.ts(PRESET_FIELDS/buildPreset/presetToValues/presetsToUrl/urlToPresets); the page itself ispreset-builder.html+src/preset-builder.js. Demo only — no SDK/core/brain change. - Demo: select predefined stream configurations on the Player page (PLAYG-115). The Player page can now be seeded with named, fully-formed configurations passed in the URL as a base64-encoded JSON array under
?presets=. When present, a new Predefined Configurations card appears at the top of the config column; every field on the page (mode, player library, manifest base URL, GAM, all Session fields, pre-roll, and the SSAI stitcher fields) is configurable per entry — onlynameis required, omitted fields keep the current/default value. Choosing an entry fills the whole form and auto-loads it; the first entry is auto-selected + loaded on page open. With no?presetsthe card is hidden and the page behaves exactly as before (the manual form remains side by side). Standard and URL-safe base64 are accepted; unknown keys are ignored and unknown enum values are skipped with a logged warning; malformed/empty/non-array input is ignored with one warning and the card stays hidden. New unit-tested helperpackages/demo/src/demo-presets.ts(decodeUrlPresets/applyPreset/encodeUrlPresets). Demo only — no SDK/core/brain change.
Fixed
- Demo: fix L-Shape content backdrop broken link (PLAYG-158). The Pre-Roll "L-Shape (content keeps playing)" experience was passing a video stream URL (HLS
.m3u8) as the backdrop image source, causing a broken<img>. The variant now uses the static L-shape backdrop image (dolby_optiview_lshape.png) — the same creative already used by thelshape_adcompanion. Demo only — no SDK/core/brain change. - Demo: content wouldn't play after switching from a Shaka preset back to an HLS.js preset (PLAYG-115). Going A(hls)→B(shaka)→A(hls) left the content paused until a manual Unload/Load.
shaka.Player.destroy()is asynchronous and clears the shared<video>element, butdestroyContentPlayer()was called synchronously andinitSdk()immediately attached a new HLS.js instance to the same element — Shaka's late detach then clobbered HLS's freshly-attached MediaSource.destroyContentPlayer()is nowasyncand awaitsshaka.Player.destroy(), andinitSdk()awaits teardown (capturing + nulling the old player refs first, with a load-generation guard after the await) before building the new player. Demo only. - Demo: switching presets inherited the previous preset's pre-roll (PLAYG-115). A preset that omitted
preRollleft the pre-roll modal's checkbox in whatever state the previous preset set, so switching from a pre-roll preset to one without still injected a pre-roll into the next stream.applyPresetnow treats pre-roll as a full on/off toggle — an omitted (orenabled:false)preRollturns it OFF — so a preset never inherits another's pre-roll injection. Covered by newdemo-presetsregression tests. Demo only. - SDK: ad audio kept playing after a session ended mid-break (PLAYG-115). Ending a session or destroying the SDK while an ad break was in flight (e.g. switching the demo's predefined config during a pre-roll) removed the overlay from the DOM but never paused the ad media element — and a detached, still-playing
<video>does not reliably stop, so the ad's audio kept playing in the background alongside the next stream.AdPlayerControllernow exposesstopActiveBreak()(pauses the ad media, clears the break-cut timer, tears down the overlay/layout, and resets break state without resuming content), called fromDolbyAds.endSessionInternal()so bothendSession()anddestroy()abort an in-flight break;AdPlayerController.destroy()also pauses the ad player defensively. Covered by newAdPlayerController.teardownandDolbyAds.schedulingteardown tests. Web-runtime teardown only — no portable-brain/conformance change. - Demo: switching presets could leave two streams playing / a stale status bar (PLAYG-115). The Player page's async
loadStream()had no guard, so selecting a new preset while the previous load was mid-awaitlet the stale continuation drive the new player and stamp an out-of-date top-bar status. Added a monotonic load-generation token (bumped ininitSdk(), captured per load) that makes every superseded continuation bail;initSdk()now also clears any lingering ad-break countdown/toast, and each load shows aLoading…status immediately. Demo only.
[0.25.0] - 2026-06-24
Added
- Video pause ads (PLAYG-126). Pause ads can now be video, not just images, across all three cores and runtimes. The break format is generalized to
format: "pause"(the legacy"pause_image"is still accepted and resolves identically), and the asset'smediaType(image/video) ×type(static/vast) selects the creative: a static image URL, a VASTCompanionAdsStaticResourceimage, a static MP4 URL, or a VASTLinearprogressiveMediaFile. Brain (PLAYG-127):PauseAdControllerresolves the genericpauseformat and carriesmediaTypethrough to the renderer; conformance fixtures cover image+video × static+vast and all three cores pass. Parser (PLAYG-128): a new dependency-freeparseVastLinearMediaFile/VastLinearParser(TS/Kotlin/Swift) extracts the first progressivevideo/mp4MediaFile plusVideoClicks/ClickThroughandImpressionbeacons. Renderers: the webAdPlayerController(PLAYG-129), AndroidOverlayAdRenderer(PLAYG-130), and iOSOverlayAdRenderer(PLAYG-131) each render a muted, play-once video surface (holds last frame) with the same resume/close affordance and click-through as the image path, and fire impression beacons on display. Demo (PLAYG-132): the Pause page offers four sources (image/video × static/VAST), with a new same-origin samplepublic/pause-linear-vast.xml. The render-skip diagnostic is unified toDA-PAUSE-AD-NO-ASSET. Docs (manifest spec +19-pause-ads.md) updated. - Demo: file a bug report (PLAYG-126). A dedicated File a Bug page (new sidebar entry,
bug-report.html) bundles the SDK's redactedexportDiagnostics()report and the@dolby-ads/mcpanalyzeDiagnostics()root-cause/findings into a pre-filled Jira Create issue screen for thePLAYGproject (Bug), opened in a new tab — the user adds a title, what happened, and repro steps, then clicks Create. Because the live SDK session lives on the Player page, the Player page snapshots the redacted report to the tab'ssessionStorageas diagnostics arrive (packages/demo/src/bug-report-store.ts) and the bug page reads it back. It is client-only: no backend and no token, so no secrets leave the page; the deep-link is URL-length-bounded and falls back to a compact body, with Copy full report / Download JSON to attach the complete redacted JSON. New unit-tested helperspackages/demo/src/bug-report.ts(buildBugReport/buildBugReportBody/buildJiraCreateUrl) andbug-report-store.ts. Demo only — no SDK/core/brain change. - Demo: remember Player-page session fields (PLAYG-123). The Player page now persists the per-user Org ID, Channel ID, Custom Asset Key, Content URL, and Ad Tag Parameters to
localStorage, so edits survive a page reload. The built-in HTML defaults remain the fallback for a fresh browser / cleared storage, and a new Reset to defaults link under the Load button clears the saved values and restores the defaults. Values are restored on boot before the e2e URL-param overrides are applied, so query params still take precedence. New unit-tested helperpackages/demo/src/field-storage.ts(restoreFields/attachFieldPersistence/clearFields, all guarded so disabled storage degrades to a no-op). Demo only — no SDK/core/brain change.
[0.24.0] - 2026-06-23
Added
Cross-platform pause-ad E2E (PLAYG-111). The shared E2E suite now covers event-driven pause ads on all three runtimes (web, Android, iOS), not just web. Two new fixtures —
pause-ad(static image) andpause-ad-vast(image via a VASTCompanionAdstag served by the mock at/vast/companion.xml) — are keyed off a newexpect.rendering.pauseAdflag. Because pause ads are event-driven (shown on content pause, dismissed on resume) they never fire the break lifecycle, so each driver uses a dedicated path that pauses/resumes the content player and asserts the coded diagnostics (DA-PAUSE-AD-SHOWN→DA-PAUSE-AD-DISMISSED): the web runner pauses the content<video>(runPauseAdScenario), the Android driver calls a newMainActivity.e2eSetContentPausedhook (ScenarioInstrumentedTest.awaitPauseAdLifecycle), and the iOS driver taps new hiddene2e-pause/e2e-resumeaccessibility controls that bridge toDemoController.e2eSetContentPaused(ScenarioUITests.runPauseAdScenario). The mock backend grows a companion-VAST endpoint andpause_image/pause_image_vastmedia variants. E2E/demo only — no SDK/core/brain change (the portablePauseAdControlleris already conformance-locked).iOS/tvOS: pause ads (PLAYG-110). Completes pause ads on Apple platforms and closes the Swift conformance parity gap — the portable
PauseAdController+VastCompanionParserare now mirrored inDolbyAdsCore, the Swift conformance CLI runs thepause-ad-*fixtures, and all three cores (TS/Kotlin/Swift) pass them (npm run conformance→ 111/111;pendingFixturesskip removed from the Swift core).BreakFormat.pauseImageand thepausePlayerAdapterEventare added to the Swift core;AVPlayerAdapteremitspause/playingfromtimeControlStatus.DolbyAds(Apple SDK) constructs the SGAI-onlyPauseAdController, feeds it the manifest, ticks it on the scheduler ticker, and bridges its show/hide to the renderer.OverlayAdRenderergainsshowPauseAd/hidePauseAd: resolves the image (staticURL, or fetch + parse VASTCompanionAds), draws a scrim + image (UIButton/UIAction-based) with a centered resume button and a top-right close button (both resume content), fades in/out, fires companioncreativeViewimpressions, and supports companion click-through (UIApplication.open, iOS). Covered byPauseAdControllerTests+VastCompanionParserTests(swift test) andOverlayAdRendererPauseTests(xcodebuild teston a simulator).Android: pause ads (PLAYG-109). The Android runtime now renders full-screen pause ads on top of paused content, completing the Kotlin pause-ad story (the portable
PauseAdControllerbrain + conformance landed earlier).ExoPlayerAdapteremits thepause/playingevents (viaonPlayWhenReadyChanged) that drive the brain;DolbyAds(Android SDK) constructs the SGAI-onlyPauseAdController, feeds it the manifest, ticks it on the scheduler ticker, and bridges its show/hide decisions to the renderer.OverlayAdRenderergainsshowPauseAd/hidePauseAd: it resolves the image (staticURL directly, or fetch + parse VASTCompanionAdsvia the new dependency-freeVastCompanionParserindolbyads-core, the Kotlin mirror of the webparseVastCompanion), draws a scrim + image with a centered resume button and a top-right close button (both resume content), fades in/out, fires companioncreativeViewimpressions, and supports companion click-through. NewDA-PAUSE-AD-NO-IMAGEdiagnostic on the render path. Covered byVastCompanionParserTest(JVM) andOverlayAdRendererPauseTest(Robolectric). Swift remains a tracked conformance parity gap (PLAYG-110).Demo: Dolby favicon (PLAYG-119). Added a Dolby
favicon.icotopackages/demo/public/and a<link rel="icon" href="/favicon.ico" sizes="any" />to the<head>of all demo pages (index,docs,manifests,preroll,vast,vod), so the browser tab shows the Dolby icon. Demo only — no SDK/core/brain change.Demo: pre-roll on the main Player page against the real backend (PLAYG-113). The Player page talks to the real ad-manifest backend, which does not serve pre-roll yet. A new Configure Pre-roll… modal (under the Session card) lets you enable/disable a pre-roll, pick the ad format (any of the five break formats), and set a delay in seconds (0 = immediate). When enabled, the demo augments the backend manifest client-side: it installs a
window.fetchinterceptor that injects aposition: "pre"break into the channel manifest the SDK fetches and polls, so the pre-roll plays at session start while every other break still comes from the backend. The modal carries a warning that this is a demo-only client-side injection; it is SGAI-only (no effect in SSAI) and the interceptor is removed on Unload. New unit-tested helpersinjectPreRoll/buildPreRollBreakinpackages/demo/src/presets.tsandinstallPreRollInterceptorinpackages/demo/src/preroll-interceptor.ts. Demo only — no SDK/core/brain change.
Fixed
- Pre-roll replayed after it finished when the manifest was re-polled (PLAYG-120). A pre-roll could play a second time a short while after completing (with content in between) — the
delayparameter was incidental.BreakScheduler.completeBreak()deletes a finished break from its tracking map, but the manifest poller keeps delivering the same manifest (the pre-roll entry persists), soupdateManifest()re-added it asPENDINGand the pre-roll re-fired (its only gate iselapsed >= delay, permanently true once the delay has passed). The scheduler now keeps a session-scoped completed-break ledger:completeBreak()records the id andupdateManifest()never re-adds a break that already played (cleared ondestroy()). This also hardens mid/post-rolls against the same re-add path. Covered by a newBreakScheduler.preRollregression test.
[0.23.2] - 2026-06-23
Fixed
- Demo: player video blew up far larger than the screen on reload / when not playing (PLAYG-112). On the main Player page, unloading and re-loading (or loading a channel that was not yet playing) could render the video much larger than the player box and overflow the page. Root cause was a CSS grid blowout, not the SDK:
#container(.player-container,aspect-ratio:16/9) is a1frgrid item in.layoutwhose defaultalign-items:stretchstretched it to the tall config column's height; with the video having no intrinsic size, that definite height fed the aspect-ratio and, via the grid item's defaultmin-width:auto, derived a huge minimum width (height × 16/9) that overrodemax-width:1200pxand overflowed off-screen (the SDK'sdolby-stagethen faithfully mirrored the oversized container). Fixed in the demo layout withalign-items:starton.layoutandmin-width:0on its grid children. Also adds an opt-in geometry diagnostic (?diag=1,window.__dolbyGeo()) that snapshots player-element sizes and flags any element exceeding the container or the container exceeding the viewport. Demo only — no SDK/core/brain change.
[0.23.1] - 2026-06-21
Fixed
- Pre-roll: brief content flash before an immediate pre-roll (PLAYG-101). On the demo pre-roll page (and for any SDK consumer), a fraction of a second of content played before a
delay:0pre-roll covered it. The SDK only paused content reactively at break start, so the customer'splay()(afterstartSessionresolves) showed content while the ad buffered.DolbyAds.startSession()now detects an immediate (position:'pre',delay 0) content-covering pre-roll and engages a content hold viaAdPlayerController.holdContentForPreRoll(): it pauses the content player and re-pauses on anyplayingevent until the pre-roll begins (which then owns the pause/resume) or the session ends. Scoped to content-covering formats (single/lshape_ad);delay>0pre-rolls play content first, unchanged. NewDA-PREROLL-CONTENT-HELDdiagnostic. Runtime/insertion only — no change to break-scheduling timing.
[0.23.0] - 2026-06-21
Added
- Demo: VOD page skip-forward/back buttons (PLAYG-100). The VOD player control bar gains two skip buttons — −15s and +30s — next to the current-time indicator. They route through
sdk.seek(), so break policies apply (no-op without an active session, suppressed duringsnapbackbreaks), and are clamped to[0, duration](the upper clamp is skipped for live/unknown durations). A new unit-tested helperpackages/demo/src/seek.ts(clampSeekTarget) holds the clamp math. Buttons auto-hide during ad breaks alongside the rest of the controls. Demo only — no SDK/core/brain change.
[0.22.1] - 2026-06-21
Fixed
- demo: the VAST page sample ad would not play in Chrome. Two causes: (1) the IMA HTML5 SDK has no HLS engine and plays the creative through the browser's native
<video>, so the original HLS creative failed off-Safari withVAST_LINEAR_ASSET_MISMATCH(VAST 403); the creative is now an MP4. (2) Self-hosting the tag on the demo origin broke Chrome's Private Network Access policy: an HTTP-localhostpage makes IMA's SDK iframe an insecure context, which Chrome blocks from fetching the loopback resource (surfaced as a CORS error). The VAST tag and MP4 are now served from a public HTTPS host with CORS (HOSTED_VAST_TAG_URI), which IMA can fetch cross-origin from any demo origin. The self-hostedpublic/sample-vast.xml+public/sample-ad.mp4and the dev-server CORS plugin are removed; the deployed demo needs no nginx CORS change. No IMA/core change. - demo (dev): the local break-manifest server (and stitcher) failed to start with "Manifest server exited on startup" in a fresh checkout/worktree. The Vite dev plugins spawn the workspace CLIs via
tsx, which imports@dolby-ads/core/*; that resolves through the packageexportsmap todist/, which is absent until@dolby-ads/coreis built. The dev plugins now passTSX_TSCONFIG_PATH=tsconfig.dev-cli.jsonsotsxresolves@dolby-ads/core/*from TS source (mirroring the demo's Vite browser-bundle aliases), lettingnpm run devwork without a prior build. Dev-only — production deployment still buildsdistvia Docker and never uses these plugins. - demo (dev): a stop-then-start of the local break-manifest server also failed with "Manifest server exited on startup".
stopChild()sentSIGTERMbut returned synchronously, so the next start spawned a new server before the OS released the port (EADDRINUSE).stopChild()now awaits the child's full exit (with aSIGKILLfallback) andstart/stopawait it; the start endpoint also surfaces the child's real error (e.g. "port 4100 is already in use") instead of the opaque generic message.
[0.22.0] - 2026-06-21
Added
- Demo: VOD page can load a published channel by ID. The VOD page gains a "Break source" toggle: keep the existing Break manifest (JSON) flow (paste JSON → spawn the local dev server → provision a channel), or switch to Channel ID to load a channel another tool already published to the ad-break server. In channel mode the SDK is pointed at the configured manifest server directly (no local server spawned, no
POST /channels); only the channel ID is required, while the hostname (defaulthttps://ads-sdk.xnappet.live) and org ID (default0bda787b-…) are pre-filled under a collapsible Advanced section. Pasting a full channel URL auto-fills host/org/channel. New unit-tested helpers inpackages/demo/src/manifest-config.ts(parseManifestChannelUrl,buildChannelBaseUrl,DEFAULT_CHANNEL_HOST). Demo only — no SDK/core/brain change. - Demo: player current-time indicator (PLAYG-98). All four web demo player stages now show an elapsed current-time indicator. The VOD, Pre-roll, and VAST pages gain a time-only overlay (no play/mute/fullscreen), and the main Player page shows the time alongside its existing controls. A new shared, unit-tested helper
packages/demo/src/player-time.tsprovidesformatPlayerTime()(m:ss→h:mm:sspast an hour;0:00for unknown/non-finite) andcreateTimeIndicator(), which polls the activePlayerAdapter.currentTime(works across HLS.js / Shaka / THEOplayer / native). The indicator hides during ad breaks and returns on resume. Demo only — no SDK/core/brain change.
[0.21.1] - 2026-06-19
Changed
- demo: self-host the VAST sample tag and point its creative at the Optiview sample HLS asset (PLAYG-97)
[0.21.0] - 2026-06-19
Added
- Customer onboarding docs consolidation (PLAYG-37 epic / PLAYG-74). New top-level
docs/customer-onboarding.mddocuments the onboarding test-matrix capability end-to-end: what it is and where it lives (@dolby-ads/onboarding,scripts/jira.sh,.windsurf/workflows/onboard.md, thee2e/onboarding mode), the standard branded demo design (template pinned to the published artefact tarballs), how to file an onboarding task (the fenced```onboardingconfig contract + a copy-pastable example), the/onboardpipeline (6 steps) and its branchless states (To Do → In Progress → In Review), plus the web-only / native-deferred and creds-local-only notes.e2e/README.mdgains an **Onboarding mode (customer-parameterized run)** section (playwright.onboarding.config.ts+E2E_ONBOARDING_DEMO_DIR+onboarding.spec.ts, driven byrunMatrix), and the demo **Install / Distribution** page (packages/demo/docs/17-install.md) cross-links the capability. Docs only — no SDK/core change. - VAST demo page (PLAYG-90 epic). New VAST page in the demo app sidebar (
packages/demo/vast.html+packages/demo/src/vast.js), mirroring the Pre-roll page: pick a content player (HLS.js / Shaka / THEOplayer / Native HLS) and a linear ad experience, then choose pre-roll (with an optional 5 s delayed sub-toggle) and/or mid-roll via checkboxes (at least one required). It creates a break channel on the manifest server (local dev CLI or the hosted/manifest/v1in production) whose ad asset is THEOplayer's public sample VAST tag (https://cdn.theoplayer.com/demos/ads/vast/vast.xml) and runs an SGAI (CSAI) session, so the VAST creative is fetched, parsed, and played by Google IMA. The format selector is restricted to the VAST-admissible linear formats (single/double/lshape_ad). AddsSAMPLE_VAST_TAG_THEO,VAST_FORMATS, andbuildVastManifest()topackages/demo/src/presets.ts; the page loadsima3.js. Demo only — no SDK/core/brain change. /onboardworkflow — customer onboarding orchestration (PLAYG-37 epic / PLAYG-73). New.windsurf/workflows/onboard.mdsequences the onboarding pipeline end-to-end from a Jira task: read the task + parse its fencedonboardingconfig (parseConfigFromIssue, echoing onlyredactConfig), move the ticket In Progress, build the branded demo (buildWebDemo), run the real source + GAM matrix (runMatrix, one BrowserStack session at a time — ordryRunwhen no creds), render the report (writeReport→<slug>-onboarding-report.{md,html}), then attach the demo zip + HTML report, post the Markdown report, and transition to In Review viascripts/jira.sh(attach/comment/transition). Unlike/developit is branchless for the onboarding task (it produces artifacts, not a repo change — To Do → In Progress → In Review). Includes a copy-pastable driver (onboard-run.mjsat the repo root — must live in the checkout so the@dolby-ads/onboardingworkspace package resolves; default-import the CJS package) that chains parse → build → matrix → report and prints the artifact paths, with creds read from gitignored local env (e2e/.env,.env.local) and never printed. Web only this epic; native families are reported as deferred. Workflow/docs only — no SDK/core change.- Onboarding report + Jira post/attach (PLAYG-37 epic / PLAYG-72).
@dolby-ads/onboardinggains a report renderer —renderReportMarkdown/renderReportHtml/writeReport({ config, matrix, sdkVersion?, demoArtifact? }, outDir)— that turns arunMatrixMatrixResult+ the customer config into a Markdown comment body and a self-contained HTML artifact (<slug>-onboarding-report.{md,html}). Both render the config throughredactConfig(GAM network code masked — the raw secret never appears), plus what-was-tested, a per-platform results table with BrowserStack build/session links, deferred native families, and thepassed/failed/skippedsummary; customer-supplied values are HTML-escaped. Newscripts/jira.sh(a bb.sh-style curl helper: creds from gitignored.env.local, base fromJIRA_BASE_URL, boundedcurl,python3-built JSON) exposescomment <key> <file>(v2 plain-text body — Jira auto-links URLs),attach <key> <file>...(X-Atlassian-Token: no-checkmultipart), andtransition <key> "<status>"(resolves name→id), so the report can be posted, the zipped demo + HTML report attached, and the ticket moved to In Review. Attach-only this epic (no hosted link); the full generate → build → matrix → report → post chain is wired by the/onboardworkflow (PLAYG-73). Offline unit tests cover the renderer including the redaction guarantee;jira.shis a network helper exercised live (likebb.sh). - Customer matrix runner — real source + GAM DAI (PLAYG-37 epic / PLAYG-71).
@dolby-ads/onboardinggainsrunMatrix(config, opts), which runs the generated, prebuilt customer demo (realcontentUrl+ GAM baked in by PLAYG-70) on the web BrowserStack matrix one session at a time against real Google IMA DAI (no mock). It reuses the PLAYG-16e2e/harness via a new onboarding mode:e2e/playwright.onboarding.config.tsserves the static prebuilt demo (E2E_ONBOARDING_DEMO_DIR) with no mock/stitcher, ande2e/web/onboarding.spec.tsasserts boot (sdk-initialized) → content plays → (GAM break → resume, no stall) → full SDK lifecycle via the demo'swindow.__dolbyE2Ehook (aderrorforbidden). The shared hook helpers are extracted fromrunner.tsintoe2e/web/hookAssertions.ts(no behaviour change to the existing suite).runMatrixderives a one-platform config per session frombrowserstack.yml(single source of truth), spawns the BrowserStack SDK with--config=playwright.onboarding.config.ts, and returns a structuredMatrixResult(per-platform pass/fail + scraped BrowserStack build/session URLs + a preserved per-platform Playwright report) for the PLAYG-72 report; native families are reported asdeferred. No new runtime deps. The live BrowserStack+GAM run is creds-gated (exercised via/run-e2e-browserstack+ the PLAYG-75 dry run); offline unit tests cover platform resolution, one-platform config generation, output/URL parsing, aggregation, anddryRun. - AI features-overview reference (PLAYG-79 / PLAYG-83). New
packages/sdk/ai/reference/features-overview.mdcovering DAI vs DAR, SGAI vs SSAI, VAST (CSAI), pre-roll/mid-roll/post-roll, break formats, player adapters, tune-in, chaining, and overlap suppression. Referenced from the onboarding agent (new "Step 4 — Explain features" capability) and the quickstart skill. Asserted ininitAi.test.ts. - VAST docs + runnable demo example (PLAYG-8 epic / PLAYG-59, VAST S7). Final VAST story — documentation consolidation plus a runnable demo example; no SDK/core/brain change. A new demo docs page VAST (CSAI) (
packages/demo/docs/18-vast.md, wired intodocs.htmlnav under Concepts & Integrations) explains client-side VAST playout via Google IMA, the linear-only supported formats (single/double/lshape_ad), the SGAI-only — never SSAI constraint (explicit), preloading, and error handling (aderror+ theDA-VAST-*diagnostics). The Manifests page gains a VAST — CSAI (IMA sample tag) preset (vast-csaiinpackages/demo/src/presets.ts;PresetAssetextended withtype: 'vast'+mimeType) that builds avastpre-roll using Google's public IMA sample linear VAST tag — create it as a channel, then play it on the Player page (the demo already loadsima3.jsfrom S6) to watch a real CSAI VAST ad. VAST is also now documented indocs/architecture.md(new CSAI section), the rootREADME.md(ad-assets note +VastAdManagerin the Android/iOS runtime rows), the native core READMEs (resolveVastAdmissionguard), andpackages/demo/docs/12-manifest.md(Try-it pointer); all aligned withdocs/dolby-ads-manifest-spec.md. Docs/demo only. - VAST E2E scenarios + BrowserStack (PLAYG-8 epic / PLAYG-58, VAST S6). The cross-platform E2E suite now exercises VAST (client-side / CSAI) ads end-to-end. The mock backend serves a self-hosted inline VAST 3.0 tag (
GET /vast/linear.xml→ the bundled/media/ad.mp4, no external ad server) and three newvast*break formats inbuildVariant. Three shared scenarios (e2e/scenarios/):vast-singleandvast-lshape-ad(playout,fulltier — real Google IMAima3.js, opt-in likegam), andvast-unsupported-overlay(minimaltier, deterministic rejection — the renderer's format guard fires before IMA, assertingaderror, noadbegin, and theDA-VAST-UNSUPPORTED-FORMATdiagnostic). A newexpect.diagnosticsassertion field on the shared scenario model lets a scenario require coded diagnostics; it is checked via thewindow.__dolbyE2Ehook (web) and the demo event log (Android/iOS), wired into all three runners (e2e/web/runner.ts, AndroidScenarioInstrumentedTest, iOSScenarioUITests). The web demo now also loads the client-side IMA loader (ima3.js) alongside the DAI loader (ima3_dai.js) so CSAI can play. VAST scenarios are auto-included in the BrowserStack suite by tier (rejection on smoke/core, playout on the full sweep). The SSAI-rejection diagnostic (DA-VAST-SSAI-UNSUPPORTED) has no runtime emitter (conformance-only, by S5 design), so it has no E2E scenario. Tests/E2E + demo loader only — no SDK/core/brain change.
Changed
- Onboarding + quickstart work without the MCP server (PLAYG-79 / PLAYG-81). The
dolby-quickstart-demoskill now embeds a canonical, version-pinned single-file demo template (with a per-player wiring table) so a runnable demo can be produced from the*.mdalone. Thescaffold_quickstartMCP tool is reframed as an optional accelerator.dolby-onboarding/AGENT.mdstep 3 anddolby-troubleshooter/AGENT.mdnow explicitly state MCP is optional. - Removed the internal
dolby-e2e-runskill from shipped artifacts (PLAYG-79 / PLAYG-82). Deletedpackages/sdk/ai/skills/dolby-e2e-run/; dropped the companion-skill bullet in.windsurf/workflows/run-e2e.md; cleaned the CHANGELOG reference.initAi.test.tsnow asserts its absence. - Clearer step-by-step getting-started docs (PLAYG-79 / PLAYG-84).
packages/demo/docs/02-getting-started.mdreworked into an explicit 9-step numbered guide (install → HTML → wire adapter → create SDK → events → session → play → verify diagnostics → clean up).01-overview.mdupdated to clarify MCP is optional. - Dedicated AI Assistance sidebar page + moved scaffold card (PLAYG-79 / PLAYG-85).
packages/demo/docs/14-ai-assistant.mdreworked into a step-by-step how-to-use / what's-possible page with a new "Explain features" step. Nav label renamed "AI Assistant" → "AI Assistance" indocs.html(idai-assistantkept stable). The "Get started" scaffold card moved off the main demo page (index.html/src/main.js) — it lives only on the AI Assistance docs page.README.mdAI section updated to clarify MCP is optional and renamed to "AI Assistance".
Fixed
- Traefik
/manifestrouter masked the demo's/manifests.htmlpage. The backend-services router usedPathPrefix(/manifest), which also matched the static demo page/manifests.htmland routed it to the break-manifest-server (404) instead of nginx.deploy/services/docker-compose.services.ymlnow usesPathPrefix(/manifest/)(trailing slash), which captures only the API namespace/manifest/v1/...and leaves/manifests.htmlto nginx. The live server was patched directly; this aligns the canonical compose so the nextscripts/deploy-services.shdoes not regress it. Infra/config only. - AI Assistance sidebar item missing on non-player demo pages (PLAYG-87). The demo app-shell sidebar was hand-duplicated in every page, and only
index.html(Player) carried the AI Assistance nav link —preroll.html,vod.html, andmanifests.htmlhad drifted and omitted it. The sidebar is now a single reusable component:packages/demo/src/sidebar.ts(SIDEBAR_NAV+renderSidebar(activeKey)) is injected at build/dev time by the VitedocsPluginvia a per-page<!-- SIDEBAR:<key> -->placeholder, so all app-shell pages share one definition (and the AI Assistance link).docs.htmlkeeps its distinct in-page documentation sidebar. Demo-only — no SDK/core/brain change. - Deployed demo: VOD / Pre-roll / Manifests use the hosted manifest server (PLAYG-86). The demo's manifest-backed pages assumed the Vite dev middleware (
/api/manifest-server/start, which spawns a localbreak-manifest-serveronlocalhost:4100) — absent from the production build, so on the deployed site VOD/Pre-roll failed with "Could not start the manifest server" and the Manifests page 404'd. A newpackages/demo/src/manifest-config.tsresolver (resolveManifestServerConfig) now points the pages at the hosted server on the same origin at/manifest/v1in production (no local-server spawn, host-agnostic) while keeping thelocalhost:4100dev-server flow fornpm run dev. The Manifests page hides its Start/Stop-server controls in production. - Services deploy: build images for the server's architecture (PLAYG-67).
scripts/deploy-services.shnow builds the stitcher + break-manifest-server images with--platform ${TARGET_PLATFORM:-linux/amd64}(and the compose pinsplatform: linux/amd64) instead of the build host's native arch. An arm64 (Apple Silicon) build shipped to the amd64 server crash-looped withexec /usr/local/bin/docker-entrypoint.sh: exec format error; cross-building via Docker buildx/QEMU fixes it.
[0.20.0] - 2026-06-19
Added
- Backend services deploy — stitcher + break-manifest-server as Docker/Traefik services (PLAYG-40 epic / PLAYG-67).
@dolby-ads/stitcher(SSAI) and@dolby-ads/break-manifest-server(SGAI) now ship as Docker containers behind the existing Traefik onads-sdk.xnappet.live, routed by their own path namespaces (PathPrefix(/ssai)→ stitcher:4600,PathPrefix(/manifest)→ break-manifest-server:4100) — no StripPrefix/base-path change (the stitcher emits relative media URLs; stateless). NewDockerfileper service builds at the repo root and bundles the CLI with esbuild into a single CJS file (the packages compile to ESM with extensionless imports, which is not standalone-Node runnable), so the runtime image needs nonode_modules. The break-manifest-server CLI gainsPUBLIC_HOST/--hostso the channel URLs it returns are rooted at the public origin (it otherwise defaulted tolocalhost). Canonical compose + samples live indeploy/services/(docker-compose.services.yml,.env.sample,stitcher.channels.sample.json); the live copy lives on the server in/srv/dolby-ads-config/. Newscripts/deploy-services.shships images registry-free (docker save | ssh docker load) and runsdocker compose up -d(user-approved);/deploygains a Services step. Demo Install / Distribution docs gain a Hosted-services section. No SDK/core/brain change. - VAST admission — conformance brain parity (PLAYG-8 epic / PLAYG-57, VAST S5). The VAST format guard (linear-only) and SSAI rejection — previously duplicated inline in each renderer (web
AdPlayerController, Android/iOSOverlayAdRenderer) — are extracted to a new portableresolveVastAdmissionbrain unit mirrored across all three cores (TS reference@dolby-ads/core, Kotlindolbyads-core, SwiftDolbyAdsCore) and locked by the cross-language conformance harness. Given{ format, ssai }it returns{ admitted, diagnostic }: SSAI →DA-VAST-SSAI-UNSUPPORTED(VAST is SGAI-only; checked first), non-linearoverlay/lshape_content→DA-VAST-UNSUPPORTED-FORMAT, else admitted (no new diagnostic codes). A newvastAdmissionconformance driver (in the TS/Kotlin/Swift CLIs) plus three fixtures —vast-single-accepted,vast-unsupported-format-rejected,vast-ssai-rejected— bring the harness to 96/96 across all cores. The three renderers now delegate their format decision to the shared unit (SGAI renderers passssai: false; the SSAI branch is conformance-only as SSAI never reads the break manifest), keeping the same observable behaviour and per-platform diagnostic message. Brain/conformance + tests only — no new public API or dependency. - iOS SDK artefact publishing — binary xcframeworks on the demo domain (PLAYG-40 epic / PLAYG-66). The Apple modules (
DolbyAdsCore,DolbyAdsSDK,DolbyAdsRuntime) are now published as zipped binary.xcframeworks hosted underhttps://ads-sdk.xnappet.live/artifacts/ios/, consumable via SwiftPM.binaryTarget(url:checksum:)with no Swift registry. Each xcframework is its own dynamic module, so a consumer adds the layer it wants plus that layer's dependency xcframeworks (DolbyAdsSDKneedsDolbyAdsCore;DolbyAdsRuntimeneedsDolbyAdsCore+DolbyAdsSDK+ the Google IMA SPM package). Newscripts/build-artifacts-ios.shtemporarily switches all SwiftPM library products totype: .dynamic(committedPackage.swiftfiles restored) so inter-module deps link dynamically (@rpath/…framework) rather than statically embedding each other, runsxcodebuild archiveper destination, copies the emitted.swiftmoduleinto each framework'sModules/(SwiftPM's archived framework omits the module interface, which otherwise breaksimport), thenxcodebuild -create-xcframework→ zip →swift package compute-checksum, and emitsmanifest.json(per-producturl+ SHA-256) plus a browsableindex.htmlwith aPackage.swiftsnippet. Verified consumable by building throwaway SPM consumers against the produced xcframeworks. Artefacts stage underpackages/demo/dist/artifacts/ios/so they ride the existing/deployrsync (new step 1d). Slices are iOS device + iOS simulator; tvOS slices are opt-in (IOS_INCLUDE_TVOS=1) and tracked as a follow-up (PLAYG-76) because the build host's tvOS platform component was not installed. New demo docs Install / Distribution iOS section + README. Tooling/docs only — no SDK/core/brain change. - Dedicated VOD (DAR/DAI) demo page (PLAYG-68). The demo gains a new VOD sidebar page (
vod.html+src/vod.js) that mirrors the Pre-roll page for VOD playback with custom inputs: selectors for Content player (HLS.js / Shaka / THEOplayer / Native HLS), Ad experience (all five break formats, used to prefill the manifest), and Insertion type (DARreplacement/ DAIinsertion), plus a VOD content URL field and an editable break-manifest JSON textarea. Run provisions the supplied manifest as a channel on the local break-manifest server and starts an SGAI session with the chosenadInsertionType, so the DAR (skip replaced window) vs DAI (resume at cue) resume behaviour is observable on the mid-roll. A newbuildVodManifest(format, midrollStart)helper (+DEFAULT_VOD_CONTENT_URL) is added topresets.ts; the VOD nav link is added across the demo pages. Demo-only — no SDK/core/brain change. - Android SDK artefact publishing — Gradle Maven repo on the demo domain (PLAYG-40 epic / PLAYG-65). The consumable Android modules (
com.dolby.ads:dolbyads-core,dolbyads-sdk,dolbyads-runtime,adapter-test-kit) are now published as a static Maven repository hosted underhttps://ads-sdk.xnappet.live/artifacts/android/maven/, consumable via a single Gradlemaven { url … }entry. The rootandroid/build.gradle.ktsappliesmaven-publishto those modules (Androidreleasevariant fordolbyads-runtime,javacomponent for the JVM libs, each with a sources jar), publishing topackages/demo/dist/artifacts/android/mavenso the repo rides the existing/deployrsync. Published POMs reference the siblingcom.dolby.ads:*modules, so the one repo resolves the whole graph (third-party Media3/IMA/coroutines deps come fromgoogle()/mavenCentral()). Newscripts/build-artifacts-android.sh(version tracks locksteplerna.jsonvia-PdolbyAdsVersion; output overrideANDROID_MAVEN_DIR) + a browsableindex.html;/deployand the demo Install / Distribution docs gain the Android section. Tooling/docs only — no SDK/core/brain change. - VAST client-side (CSAI) playout on iOS/tvOS (PLAYG-8 epic / PLAYG-56, VAST S4).
vastassets in linear breaks (single,double,lshape_ad) are now fetched, parsed, and rendered by the Google IMA iOS SDK's client-side surface — the Swift counterpart of web S2 / Android S3. NewDolbyAdsRuntimeVastAdManagingprotocol + realImaVastAdManagerdrivesIMAAdsLoader→IMAAdsRequest(adTagUrl:)→IMAAdsManager(rendered inside the overlayIMAAdDisplayContainer), distinct from the DAI pod-servingGamStreamManager(IMAStreamManager/IMAPodStreamRequest).OverlayAdRendererroutes VAST assets to it, emitting the normaladbegin/quartile/adend/adbreakendsequence and resuming content. Same guards/diagnostics as web/Android: non-linearoverlay/lshape_content→DA-VAST-UNSUPPORTED-FORMAT; IMA SDK unavailable →DA-VAST-IMA-SDK-MISSING; IMA load/play failure →DA-VAST-IMA-ERROR(all also raiseaderrorand recover); a break-cutTask.cancel()propagates as cancellation (not an error). New renderer diagnose seam (AdRenderer.setDiagnoseHandler, default no-op, wired fromDolbyAds) so renderer-sideDA-VAST-*codes flow through the SDK diagnostic stream. The CSAI engine sits behind an injectable factory soxcodebuild testunits cover routing + guards without the (headless-incapable) IMA SDK; the device playout path is exercised by native e2e (S6). VAST remains SGAI-only. - Web SDK artefact publishing — install from the demo domain (PLAYG-40 epic / PLAYG-64). The publishable web packages (
@dolby-ads/core,sdk,adapter-hlsjs,adapter-shaka,adapter-theoplayer,adapter-test-kit,mcp) are now built intonpm packtarballs hosted underhttps://ads-sdk.xnappet.live/artifacts/web/, installable directly vianpm install <tarball-url>with no private registry. A newscripts/build-artifacts-web.mjs(rootnpm run build:artifacts:web) rebuilds each package, rewrites its internal@dolby-ads/*dependencies to the matching endpoint tarball URLs (so a fresh consumer resolves the whole graph from the domain), and emits amanifest.json(versions + SHA-256 + npm integrity) plus a browsableindex.html. Artefacts stage underpackages/demo/dist/artifacts/web/so they ride the existing/deployrsync (no--deleteregression); the/deployworkflow gains the build + amanifest.jsonverification curl. New demo docs page Install / Distribution (packages/demo/docs/17-install.md) + README section. The web packages remain ESM browser libraries (consume via a bundler). Android/iOS artefacts and the stitcher/break-manifest server deployment follow in later epic stories. Tooling/docs only — no SDK/core/brain change. - VAST client-side (CSAI) playout on Android (PLAYG-8 epic / PLAYG-55, VAST S3).
vastassets in linear breaks (single,double,lshape_ad) are now fetched, parsed, and rendered by the Google IMA Android SDK's client-side surface — the Kotlin counterpart of the web S2. Newdolbyads-runtimeVastAdManager(interface + realImaVastAdManager) drivesAdsLoader/AdsManager/AdDisplayContainerplus aVideoAdPlayerbacked by the SDK's ad ExoPlayer (distinct from the DAI pod-servingGamStreamManager).OverlayAdRendererroutes VAST assets to it, emitting the normaladbegin/quartile/adend/adbreakendsequence and resuming content. Same guards/diagnostics as web: non-linearoverlay/lshape_content→DA-VAST-UNSUPPORTED-FORMAT; IMA SDK unavailable →DA-VAST-IMA-SDK-MISSING; IMA load/play failure →DA-VAST-IMA-ERROR(all also raiseaderrorand recover). New renderer diagnose seam (AdRenderer.setDiagnoseHandler, wired fromDolbyAds) so renderer-sideDA-VAST-*codes flow through the SDK diagnostic stream. The CSAI engine sits behind an injectable factory so Robolectric tests cover routing + guards without the (headless-incapable) IMA SDK; the device playout path is exercised by native e2e (S6). VAST remains SGAI-only. iOS CSAI follows in S4. - Pre-roll demo: content-player + ad-experience selectors (PLAYG-61). The Pre-roll page gains a Content player selector (HLS.js / Shaka / THEOplayer / Native HLS) and an Ad experience selector covering all five break formats (single, double-box, L-shape ad, L-shape content, overlay). The per-library content-player construction/load/teardown (previously inline in
main.js) is extracted to a sharedpackages/demo/src/player-factory.jsreused by both the Player and Pre-roll pages; per-format pre-roll manifests are produced by a newbuildPreRollManifest(format, delaySeconds)helper (+AD_EXPERIENCES) inpresets.ts. THEOplayer renders into its own#theoPlayerEl. Demo-only — no SDK/core/brain change. - Dedicated pre-roll demo page (PLAYG-60). The demo gains a new Pre-roll sidebar page (
preroll.html) that exercises pre-roll support with a single click: it starts the local break-manifest server, creates a pre-roll channel from a shared preset, and runs an SGAI session (HLS.js, GAM off) so the pre-roll plays at session start. A "Delayed pre-roll (5s)" checkbox switches between the immediate and delayed variants, an AD countdown toast overlays the player while the break runs, and an event log shows theadbreakbegin/adbegin/adbreakendflow. The sample manifests are now shared between the Manifests and Pre-roll pages viapackages/demo/src/presets.ts. Demo-only — no SDK/core/brain change.
[0.19.0] - 2026-06-18
Added
- DAI vs DAR ad insertion type (PLAYG-26). New per-session
SessionConfig.adInsertionType('replacement'|'insertion', default'replacement') controls how an ad break relates to the content timeline.replacement(DAR) keeps the historical resume behaviour (the replaced window is skipped only when a break carries an explicitresumeOffset);insertion(DAI) resumes content at the exact pre-break cue. A break's manifestresumeOffset(now implemented) overrides the mode default in both cases. Resume-seek is applied on thepts(VOD) timebase for content-pausing break formats; it is ignored in SSAI mode (the stitcher controls insertion server-side, surfaced via the newDA-INSERTION-TYPE-IGNORED-SSAIdiagnostic). New portableresolveResumePointbrain unit mirrored across all three cores (TS/Kotlin/Swift) and locked by theresume-point-dar-daiconformance fixture. New diagnosticsDA-INSERTION-TYPE-RESOLVEDandDA-INSERTION-TYPE-IGNORED-SSAI. NewAdInsertionTypetype exported from@dolby-ads/core. Demo gains a DAR/DAI selector (?adInsertionType=E2E param). WEB e2e addssingle-dai/single-darscenarios (PLAYG-52, minimal tier) asserting the post-break content resume position (expect.resumePosition) — DAI resumes at the cue, DAR (with the window-skipresumeOffset) resumes past the replaced window — covered locally and by the BrowserStack smoke. - VAST client-side (CSAI) playout on web (PLAYG-8 epic / PLAYG-54, VAST S2).
vastassets in linear breaks (single,double,lshape_ad) are now fetched, parsed, and rendered by the Google IMA client-side SDK (ima3.js) on web. NewVastAdManager(@dolby-ads/core, exported alongsideisImaCsaiSdkAvailable) drives the IMA CSAI surface (AdDisplayContainer/AdsLoader/AdsManager) — separate from the DAI-onlyGamStreamManager— andAdPlayerControllerroutes VAST assets to it, emitting the normaladbegin/quartile/adendevent stream and resuming content after the break. Guards surface structured diagnostics: VAST in a non-linearoverlay/lshape_contentbreak →DA-VAST-UNSUPPORTED-FORMAT; missingima3.js→DA-VAST-IMA-SDK-MISSING; IMA load/play failure →DA-VAST-IMA-ERROR(all also raiseaderrorand recover). VAST remains SGAI-only — SSAI never reads the break manifest, soDA-VAST-SSAI-UNSUPPORTEDis enforced at the brain/conformance layer (S5). Android/iOS CSAI playout follow in S3/S4. - VAST asset type — manifest model + diagnostics taxonomy (PLAYG-8 epic / PLAYG-53, VAST S1). Foundational model work for client-side VAST (IMA CSAI) ads.
@dolby-ads/corenow exports aVastAssetinterface (type: 'vast',uri: string | AssetUri[]) in theAssetunion, plus runtime type guardsisStaticAsset/isVastAsset/isVendorAsset. The Kotlin/Swift cores already carryAsset.type/urithroughManifestServiceunchanged; new parse tests pin that avastasset survives parsing and that unknown asset types remain forward-compatible. New diagnostic codes added to the taxonomy (DA-VAST-UNSUPPORTED-FORMAT,DA-VAST-SSAI-UNSUPPORTED,DA-VAST-IMA-ERROR,DA-VAST-IMA-SDK-MISSING) with TS/Kotlin/Swift catalogs, MCP remediations, and AI knowledge-base entries. VAST is SGAI-only and limited to linear formats (single,double,lshape_ad); playout/routing lands in later stories (S2–S4). No CSAI playout yet. - Pre-roll support with optional delay (PLAYG-39). Breaks can now declare
position: 'pre'to trigger relative to session start instead of the timebase-derivedstartvalue. An optionaldelayfield (seconds, default 0) controls how long after playback begins before the pre-roll fires. Not supported in SSAI mode. NewBreakPositiontype exported from@dolby-ads/core. Conformance fixtures added (pre-roll-immediate,pre-roll-delayed).
Fixed
- WEB E2E chromium content no longer stalls at
currentTime 0locally — uses system Chrome + mock-served content (PLAYG-38). Local Playwright runs (e.g.chromium,single-replace) stalled: the content<video>never advanced pastcurrentTime 0, so the PTS break (scheduled at 4s) never fired and the test timed out (waitForFunction 45000ms). The ticket hypothesised a flaky external CDN, but the real root cause was the test browser: Playwright's bundled Chromium is the open-source build with no proprietary codecs —MediaSource.isTypeSupported('video/mp4;codecs="avc1…,mp4a.40.2"')isfalse— so hls.js failedaddSourceBufferwithbufferAddCodecErrorand never buffered a frame (silently, since the demo has no content-side hls error handler). HLS in the wild is H.264/AAC, so the e2e content (and the existingad.m3u8) are too; the bundled Chromium could never decode them (the suite's green runs were on BrowserStack real browsers). Two-part fix (E2E harness only — no SDK/core/brain change): (1) the localchromiumPlaywright project now useschannel: 'chrome'(system Google Chrome, which has H.264/AAC; install once vianpx playwright install chrome); and (2) a short HLS (fMP4/CMAF) rendition of the bundledcontent.mp4is committed (/media/content.m3u8+content_init.mp4+content{N}.m4s) and served by the mock so runs no longer depend on an external CDN — matching the native harnesses (Android/iOS), which already play a mock-served local clip (/media/content.mp4); hls.js/shaka can't play the progressive.mp4, hence the web HLS rendition. The web driver resolves its VOD content via a single source of truth (contentVodUriine2e/mock-server/media.ts, mirroringadVideoUri);E2E_CONTENT_HLSstill overrides. Added a mock-server regression test (default web VOD content resolves to the mock-local origin, no external host) and documented thechannel: 'chrome'requirement +ffmpegregeneration command ine2e/README.md. Verified:chromiumminimal-tier scenarios (incl.single-replace) now play content pastcurrentTime 0and fire the 4s break. Harness/docs only — no SDK runtime code changed. - Default ad player now routes progressive (MP4) creatives to native
<video>instead of HLS.js (PLAYG-28).@dolby-ads/sdk's default ad-player factory previously wrapped the ad<video>inHlsJsAdapterwheneverHls.isSupported()(every MSE browser: Chrome, Edge, Firefox), so aSTATICprogressive creative (MP4) was fed tohls.loadSource()and failed fatally withmanifestParsingError— the ad never played. The default factory now returns a newRoutingAdAdapterthat picks the playback technology from the creative atload()/preload()time: progressive files (.mp4/.m4v/.webm/.ogv/.ogg/.mov, or avideo/*Content-Type) play viaNativeVideoAdaptereven on MSE browsers, while HLS playlists (.m3u8/*mpegurl) useHlsJsAdapter. Detection is a synchronous URL-extension fast path with aContent-TypeHEADprobe for extensionless URLs; anything that can't be classified (e.g. GAM pod manifests) stays on HLS.js, and platforms without MSE keep using native HLS. The adapter rebuilds its inner tech if a later break needs a different format, and replays pre-load()event subscriptions. NewRoutingAdAdapterandselectAdTechexported from@dolby-ads/sdk. CorePlayerAdapterinterface unchanged; web SDK only (no brain/conformance change). tune-inWEB E2E scenario now firesadbreakbeginon slow-loading real browsers (PLAYG-29). On BrowserStack thetune-inscenario timed out (content played, session started, but noadbreakbegin). Root cause was a scenario-timing defect, not an SDK bug: the SDK matches wallclock breaks against the injected clock (Date.now()), not the stream'sEXT-X-PROGRAM-DATE-TIME, so the suspected "stale PDT" was irrelevant and the matching was correct. The break (start: -2s,duration: 8s,minBreakDurationSeconds: 5) could only fire via the on-time path (first poll within ~3s of session start); its tune-in branch was mathematically unsatisfiable (it needstimeDiff > 5sandremaining ≥ 5s, but an 8s break started 2s ago has ≥5s remaining only for the first ~1s). Local loads are <3s so it passed (testing on-time, not tune-in); the slower real-browser + external-CDN load exceeded 3s, so the break was skipped. Fix (E2E harness only — no SDK/core/brain change): the break is widened tostart: -10s,duration: 60sso the tune-in branch is always taken with ample remaining (tolerant of first-poll latency up to ~45s), and a newBreakSpec.oncePerSessionflag delivers the in-progress break only on the session's first manifest poll (the mock tracks per-session poll count) so the completed break is never re-advertised/re-triggered. Added mock-server regression tests (matching-math across first-poll latencies +oncePerSessiondelivery) and coreBreakSchedulerwallclock tune-in tests. Updatede2e/README.mdanddocs/browserstack-web-e2e.md. Harness/docs only — no SDK runtime code changed.- WEB E2E iOS Safari reds were an outdated RCA + matrix gap, not an SDK defect (PLAYG-36). The PLAYG-25 sweep failed 10/10 on iPhone 15 Pro + iPad Pro, originally attributed to "iOS Safari has no MSE so hls.js can't run." That premise is outdated: iPhone Safari gained Managed Media Source (MMS) in iOS 17.1 (hls.js 1.5+ drives it; this repo ships hls.js 1.6.16), and iPad Safari has had native MSE since iPadOS 13 — so hls.js works on both. The real gaps were (1)
e2e/browserstack.ymlpinned the iPhone leg to iOS17(likely pre-MMS) and (2) the demo's content player never fell back to native HLS on the genuine MSE-less tail (iOS ≤ 17.0, old WebViews). Fixed by pinning the iPhone leg to iOS 17.1+ and adding a native-HLS content path to the demo: a new "Native HLS" player option (and?player=nativeURL param) that reuses the SDK's existingNativeVideoAdapter(assigns the.m3u8tovideo.src), auto-selected whenHls.isSupported()isfalse. Selection now flows through a pure, unit-testedresolveContentPlayerLibhelper;nativewas added to the E2E web adapter set (WEB_ADAPTERS/AdapterId) so the native path can be exercised explicitly (e.g. on macOS Safari). Corrected the iOS write-ups indocs/browserstack-web-e2e.mdandpackages/demo/docs/03-adapters.md. Harness/demo only — no SDK runtime code changed. - BrowserStack Firefox e2e was a
browserNamemis-config, not an autoplay block (PLAYG-35). The Firefox legs ine2e/browserstack.ymlusedbrowserName: firefox, which the BrowserStack Playwright flow rejects (Invalid 'browser'. Use … 'playwright-firefox') — the browser never launched and every test aborted in ~17 ms, which was previously mis-diagnosed as "desktop Firefox blocks autoplay (gesture is mobile-only)". Fixed tobrowserName: playwright-firefox(both Windows 11 + macOS legs). With the fix, Firefox launches and muted autoplay works: thewindow.__dolbyE2Ehook +DA-SESSION-STARTEDpass and content plays to the break (verified live on the macOS Sequoia leg). Stock Firefox 150 plays the full flow fine. The remainingsingle-replacefailure on Playwright's Firefox (a patched Nightly build) is a separate harness limitation — the two-<video>overlay decode hand-off fails (NS_ERROR_DOM_MEDIA_DECODE_ERR/AppleVTDecoderlocally; atoBeVisibledeserialization error on the BrowserStack driver) — so that one scenario is nowtest.skip-ped onfirefoxwith an accurate rationale. Added a localfirefoxPlaywright project as a regression guard for session/autoplay. Corrected the root-cause write-up indocs/browserstack-web-e2e.md. Harness/docs only — no SDK runtime code changed.
[0.18.0] - 2026-06-17
Added
- Standalone break manifest server (PLAYG-27). New package
@dolby-ads/break-manifest-server— a lightweight Express server that stores and serves static break manifests with production-compatible URLs (/manifest/v1/{org-id}/channels/{channel-id}). Configured with an org ID at startup (--org-idCLI flag orORG_IDenv var). Endpoints: POST (create), GET (retrieve/list), DELETE. The demo page gains a new Manifests sidebar section to create/list/delete manifest endpoints with a one-click server launch via the Vite dev proxy. - BrowserStack WEB SDK E2E harness + verified full-matrix sweep (PLAYG-16 epic / PLAYG-25). The existing Playwright + mock-backend E2E suite can now run on BrowserStack Automate real browsers/devices via the BrowserStack Node SDK + Local tunnel, reusing the same shared scenario fixtures. The run builds the demo and serves the static output, and tunnels the local mock (
:4500) + demo to the cloud browser. New:e2e/browserstack.yml(full 9-platform matrix — Win/macOS Chrome/Edge/Firefox + iPhone/iPad Safari + Android Chrome,browserVersion: latest),e2e/browserstack.smoke.yml(single-platform smoke),e2e/playwright.browserstack.config.ts, and thee2e:web:browserstack/:smoke/:serialscripts. Single-session correctness: the SDK fans theplatforms:matrix into concurrent workers and ignoresworkers: 1, so on a single-parallel-session plan the surplus workers fail in ~15 ms withECONNREFUSEDon the SDK session socket; the newscripts/run-browserstack-serial.mjs(e2e:web:browserstack:serial) invokes the SDK once per platform (one session at a time) and prints a per-platform PASS/FAIL summary. Credentials are read from a gitignorede2e/.envonly (viadotenv-cli);@playwright/test/playwrightare pinned to the version the BrowserStack SDK supports. A new/run-e2e-browserstackworkflow drives it interactively (on demand; pipeline integration is intentionally deferred). The live matrix is verified green on all Chromium platforms (Win/macOS Chrome+Edge, Android Chrome); two cross-platform coverage gaps were filed — desktop Firefox autoplay (PLAYG-35) and iOS Safari/WebKit needing a native-HLS demo path since hls.js requires MSE (PLAYG-36). Full setup + verified results indocs/browserstack-web-e2e.md. - Portable E2E assertion surface + rich diagnostics + mobile autoplay gesture (PLAYG-19). Under
?e2e=1the demo installswindow.__dolbyE2E— an ordered, serialisable timeline of SDK events + coded diagnostics with player-state snapshots — which the runner asserts against (reliable on real mobile Safari/Android, where DOM-text polling is flaky), with a#eventLogfallback. The runner attaches the event/diagnostic timeline + browser console logs to every report (traces/videos/screenshots on failure) and performs a mobile-guarded tap so muted autoplay is allowed on real devices (no-op on desktop). - Expanded WEB E2E ad-format coverage + transition-robustness (PLAYG-21/23). Added an overlay image (non-linear) scenario and a full-screen (
shared-element) scenario for the inline-vs-full-screen axis; all break durations normalised to 5–10s. Every scenario now asserts content resumes after the break (content→break→content, no stall) in addition to in-break ad progress and a no-aderrorguard. - GAM + SSAI WEB E2E workflows (PLAYG-22). GAM is not opt-in — the GAM workflow runs whenever
E2E_GAM_NETWORK_CODEis set (real Google DAI), and is skipped with an explicit message otherwise.E2E_SSAI=1additionally starts + tunnels a single-channel@dolby-ads/stitcherand runs the demo inmode=ssai, asserting stitched play-through. The break-manifest is always mocked; only ad playout touches real Google. - Overlay break format: native rendering + image (non-linear) overlay assets across all three platforms (PLAYG-7). The
overlaybreak format is now rendered natively on Android (OverlayAdRenderer, ExoPlayer/UIKit) and iOS/tvOS (OverlayAdRenderer, AVFoundation/UIKit), at parity with the web runtime: the ad surface is positioned/sized from the manifestposition/size/opacityand the content player is not paused (per-format pause policy — overlay ads play on top of playing content; all other formats remain full-surface + paused). Overlay assets may now be images (mediaType: "image") rendered as an<img>(web) /ImageView(Android) /UIImageView(iOS), held on screen for the asset's optionalduration(seconds, falling back to the break duration). Image overlays are preloaded (decoded ahead of the break) for an instant, flash-free transition, and on load failure the SDK dispatchesaderrorand ignores that asset (noadbegin) while remaining assets play and content keeps running — matching the conformance-locked order insdk-overlay-aderror. The native core models/parsers (KotlinBreakManifest/ManifestService, SwiftBreakManifest/ManifestService) gained the overlay layout fields (position/size/opacity) andAsset.duration, with new unit tests; web/Android/iOS runtimes gained image-rendering tests (Jest / Robolectric / XCTest).
Changed
- Development workflow: plan is posted to the Jira ticket during planning; no IDE plan-mode approval gate. The plan is now posted to the ticket as soon as it is drafted (the issue/Idea comment is the canonical baton), decoupled from approval. The IDE "click implement" plan-approval gate is removed; the agent instead awaits the user's explicit go-ahead (chat or ticket) before creating the branch and implementing (step 2). Lockstep across
AGENTS.md(source of truth),.windsurf/workflows/develop.md,.devin/playbooks/develop.md,.devin/README.md,docs/jira-workflow.md, andCONTRIBUTING.md. Docs-only/process change — no SDK runtime code affected. - Development workflow: Jira ticket-title conventions, bug/epic linking, full-sweep gating, before/after repro comments, plan effort estimates, and in-repo plans. Locked Jira title conventions for tickets the agent creates — Stories use Action-Object
[Component] Action Verb + Object; Bugs use[Component/Area] Behavior/Action + Result + [Context/Condition]with a precise verb (no vague "broken/fails/errors out"). Bugs found while testing an epic are linked to it (parent = <EPIC-KEY>); bugs found separately may be parentless. The full BrowserStack e2e sweep is customer-requested only (the plan must confirm scope), with results recorded on a dedicated Story under the epic (EPIC) or a comment on the ticket (single). Bug reproduction results are posted as a comment on the ticket before and after the fix. Every plan includes an Effort estimate (expected tokens + implementation-cycle duration) and is committed at.windsurf/plans/<slug>.mdon the task branch. UpdatedAGENTS.md(source of truth),docs/jira-workflow.md,.windsurf/workflows/develop.md,.windsurf/workflows/run-e2e-browserstack.md,.devin/playbooks/develop.md, andCONTRIBUTING.md. Docs-only/process change — no SDK runtime code affected. - Development workflow: "In Review" lifecycle, branch-on-approval, ticket keys, and PR-based completion. The Jira status model is now To Do → In Progress → In Review → Done for the epic, its stories, and the EPIC-mode
Idea(previously no "In Review" stage). The EPICIdeais created and moved straight to In Progress at plan start and to In Review when the plan is ready (Done on approval). Planning stays onmain— the branch is created only after plan approval; single-issue Jira items move to In Progress at planning start but still defer the branch. Branch names carry the uppercase ticket key (<type>/<KEY>-<slug>) and Jira-mode commit titles areKEY type: subject. Bug-like work must establish a reproduction before fixing (stop and ask if it can't be reproduced). Completion is now via a Bitbucket pull request instead of a local--no-ffmerge tomain: the agent opens the PR, processes review comments on request, and merges the PR on explicit approval (Bitbucket REST API over curl with an Atlassian API token with scopes — app passwords are deprecated; Devin uses native Bitbucket OAuth; no-token fallback prints the create-PR URL). An epic reaches Done only when all its child tickets are Done (re-checked via JQL whenever any ticket completes), otherwise it stays In Review. UpdatedAGENTS.md(source of truth),docs/jira-workflow.md,.windsurf/workflows/develop.md,.windsurf/workflows/cut-version.md,.devin/playbooks/develop.md,.devin/README.md, andCONTRIBUTING.md. Docs-only/process change — no SDK runtime code affected. - Development workflow: EPIC-mode "Idea" planning stage (heavy plan review in Jira). In Jira EPIC mode, the plan goes through a dedicated
Ideaticket (a child of the epic) before any branch or story: reviewers leave feedback as comments on the Idea, and asking to "review the comments" reworks the plan and re-comments it until an explicit plan approval, after which theStorychildren are created and implementation begins. TheIdeaissue type is discovered via project metadata; resume keys on the Idea status. (The Idea/epic status transitions are described by the "In Review" lifecycle entry above.) Docs-only/process change — no SDK runtime code affected.
Fixed
- Mis-configured
shared-elementinsertion no longer stalls content off-iOS (PLAYG-30).shared-elementplays the ad through the content player's own<video>, which only works where playback is native HLS (iPhone/iPod). When it was explicitly configured on any other platform — where the content engine is MSE-based (HLS.js/Shaka) andvideo.currentSrcis a non-reloadableblob:URL — the post-ad restore failed and content silently stalled atcurrentTime 0.resolveInsertionModenow guards the mis-configuration: on a non-iPhone/iPod UA a configuredshared-elementfalls back to the defaultoverlayinsertion, logs aconsole.warn, and emits the new structured diagnosticDA-SHARED-ELEMENT-UNSUPPORTED(categorybreak, levelwarn, added to the taxonomy and regenerated across the TS/Kotlin/Swift catalogs +ai/reference/error-codes.md). Thesingle-fullscreenE2E scenario is markedrequiresSharedElementand expected-skipped on non-WebKit browsers (it only exercises real shared-element on iOS native HLS). iPhone/iPod and theauto/adaptive/overlaypaths are unchanged.
[0.17.0] - 2026-06-15
Added
- Version API (PLAYG-1) — every component now exposes a simple, public way to read the current SDK version, all sourced from the monorepo lockstep version:
- Web:
DolbyAds.versionstatic accessor on@dolby-ads/core(inherited by@dolby-ads/sdk). - Android:
DolbyAds.versioncompanion accessor ondolbyads-sdk. - iOS:
DolbyAds.versionstatic property onDolbyAdsSDK. - Stitcher:
GET /versionendpoint returning{ "version": "x.y.z" }; the version is also re-exported from@dolby-ads/core/serverand@dolby-ads/stitcher.
- Web:
[0.16.0] - 2026-06-15
Changed
- Development workflow: optional Jira mode + relaxed git push policy (PR deferred). The branch-first
/developflow now runs in three modes — IDE-only (default, zero Jira calls), EPIC (feature with oneStoryper plan step), and single-issue (bug-like RCA / feature-like) — detected from the Jira issue type via the Atlassian remote MCP. Plans/RCA, branch name, test results, and final results are posted as issue comments; statuses run To Do → In Progress → Done (the same three-state workflow for the epic and its stories — the epic moves to In Progress when the plan is commented and to Done on merge tomain), with Done gated on explicit merge approval. The repo is no longer strictly local-only: the agent maygit pushthe task branch andmain(after merge and after/cut-version); there is still no PR, no tags, and no publish./deploystill never pushes git. UpdatedAGENTS.md(now the agent-neutral source of truth),.windsurf/workflows/{develop,cut-version,deploy}.md, addeddocs/jira-workflow.md, a rootCONTRIBUTING.md, and a cross-agent Devin adapter (.devin/playbooks/develop.md+.devin/README.md). Docs-only/process change — no SDK runtime code affected.
[0.15.0] - 2026-06-15
Added
- Client-side SSAI mode (
mode: 'ssai') in the web SDK. A newDolbyAdsmode that plays a single pre-stitched stream from the@dolby-ads/stitcherservice on the content player instead of client-scheduling breaks. A new internalSsaiControllercreates the IMA DAI stream (for astream_id), builds the stitcher master URL, loads it into the contentPlayerAdapter, forwards in-stream timed metadata to IMA for ad tracking, and re-emits the normal ad-event stream (adbreakbegin/adbegin/quartiles/adend/adbreakend). No manifest polling, break scheduling, or ad-player overlay runs in this mode. New config:mode,stitcherBaseUrl,maxBitrate,autoplayonDolbyAdsConfig. Requires a content adapter exposingvideoElement(HLS.js/Shaka/native-video) plusgam.networkCodeand acustomAssetKey. - Portable
buildStitcherMasterUrlURL builder, conformance-locked across all three cores. Builds/ssai/v1/{orgId}/{channelId}/master.m3u8?stream_id=…&max_bitrate=…with a single RFC 3986 percent-encoder applied to both path segments and query keys/values (deliberately avoidingencodeURIComponent/URLSearchParamsquirks) so TS, Kotlin (buildStitcherMasterUrlindolbyads-core), and Swift (DolbyAdsCore) produce byte-identical output. Locked by newstitcher-master-url-v1/stitcher-master-url-invalidconformance fixtures —npm run conformance78/78. - Stitcher
max_bitraterendition filtering (replacesdevice_type). The SSAI stitcher master endpoint accepts an optionalmax_bitrate(bits/sec) query param and drops#EXT-X-STREAM-INF/#EXT-X-I-FRAME-STREAM-INFvariants whoseBANDWIDTHexceeds it (keeping#EXT-X-MEDIA; fail-open keeps the single lowest variant if none qualify). The previous client-drivendevice_typeparam and server-side per-device variant targeting (selectSingleAsset) were removed — device targeting, if needed, is a server/channel-config concern, not a client knob. The break manifest'stargeting.deviceTypetype is unchanged. timedmetadatacapability on thePlayerAdaptercontract (TS/Kotlin/Swift) plus a player-agnosticTimedMetadataCuetype. Content adapters (HLS.js, Shaka, THEOplayer, native<video>, ExoPlayer, AVPlayer) emit it for each in-stream marker (ID3 /EXT-X-DATERANGE/ DASH emsg);GamStreamManager.processTimedMetadataforwards markers to IMA DAI for SSAI ad tracking.- New diagnostic codes
DA-SSAI-SESSION-FAILEDandDA-SSAI-IMA-ERRORin the shared taxonomy (TS/Kotlin/Swift catalogs regenerated), with MCP remediations and SDK knowledge-base entries. - Demo SSAI support. An Ad Insertion Architecture toggle (SGAI/SSAI) plus an SSAI Stitcher panel (stitcher base URL, stitcher origin URL, max bitrate, autoplay, snap policy, port) and a Vite dev middleware that spawns/stops a local
@dolby-ads/stitcherCLI configured from the form fields (POST /api/stitcher/start|stop). Addstsx+@dolby-ads/stitcherdemo dev-deps and a samplechannels.json. Shared Session panel (Org ID, Channel ID, Custom Asset Key, Content URL, Ad Tag Params) used by both modes; the Stitcher Origin URL is a separate field (defaults to the Content URL, falls back when blank) since an operator's SSAI origin may differ from the SGAI playback URL. The mode toggle locks while a session is loaded.
Fixed
- Stitcher now follows redirects and resolves relative variant/segment URIs against the post-redirect final URL. Origins that redirect a stable entry URL to a session/edge host (e.g. live CDNs) previously produced wrong absolute URLs (resolved against the requested URL), so the media endpoint returned
502. The HTTP seam now exposes the final URL after redirects (getTextResolved), and the master rewrite, media segment absolutization, and ad-source fetch all use it. Locked by a new server test. - Stitcher now absolutizes URI-bearing media-playlist tags (
#EXT-X-MAP,#EXT-X-KEY, etc.), not just segment URIs. The fMP4 init segment (#EXT-X-MAP:URI) and decryption-key URIs were left relative, so the player resolved them against the stitcher's own domain →404/400(fMP4 streams failed to initialize).absolutizeSegmentsnow rewrites theURI="…"attribute on every tag (header, per-segment, footer) against the origin. Locked by a new server test.
Notes
- The SSAI orchestration (
SsaiController+mode: 'ssai') is currently web-only and tracked as a parity gap for the native cores (runtime/IMA-bound, no headless mode) inconformance/PENDING-PARITY.md; the purebuildStitcherMasterUrland thetimedmetadataseam already exist in all three cores.
[0.14.0] - 2026-06-13
Added
- SSAI stitcher now stitches
doubleandlshape_adbreaks as a fullscreensinglead (configurable, default on). Both formats carry a real fullscreen ad video alongside a companion, so the stitcher splices that ad video fullscreen and drops the companion — a clean, lossy-but-valid SSAI degradation. Controlled by a newstitchCompositedAsSinglesetting (server-wide default onStitcherConfig, optional per-channel override onChannelConfig, also readable from theSTITCHER_CONFIGJSON), defaulting totrue; set it tofalseto stitch only genuinesinglebreaks.lshape_content(backdrop image, no ad video) andoverlay(content keeps playing) are never stitched. ExposesDEFAULT_STITCH_COMPOSITED_AS_SINGLE. Covered by newselectVariantunit tests, on/off pipeline tests, adouble-degraded-singlegolden fixture, and server integration tests; contract + docs updated.
[0.13.0] - 2026-06-12
Added
- Server-side ad stitcher (
@dolby-ads/stitcher) — Phase 1. A new npm-workspace package: a stateless, control-plane HLS manifest transform that bakessingle-format linear breaks into a channel's content stream at a segment boundary using Google DAI pod serving (SSAI), complementing the existing client-side SGAI. It reuses the conformance-locked portable units from a new@dolby-ads/core/serversubset entry (the manifest parserparseBreakManifest+buildGamPodUrl) without pulling any player/DOM/IMA code. DAI is hybrid: the client owns the IMA stream session (streamId) and ad tracking; the stitcher requests the pod (samebuildGamPodUrl) and splices it in, so the server holds no per-session state. ExposesGET /ssai/v1/{orgId}/{channelId}/master.m3u8(variant URIs rewritten to the media endpoint) andGET …/media/{variantId}.m3u8(content media playlist with breaks spliced, wrapped inEXT-X-DISCONTINUITY, content segments absolutized to the origin, ad segments referencing DAI URLs with timed metadata preserved). Boundary snapping is configurable per channel (snapPolicy:start|end|nearest, defaultnearest); non-singleformats are filtered out; ad-side failures fail open to unmodified content. Break manifest fetch + content origin are owned server-side (per-channel preconfigured). Canonical contract inpackages/stitcher/CONTRACT.md; design indocs/ssai-stitcher.md. Covered by unit + golden-fixture + Express integration tests. The splice/boundary logic is server-only and intentionally not part of the cross-language brain conformance. @dolby-ads/core/serversubset entry exporting the server-safe, pure units (parseBreakManifest,buildGamPodUrl,isGamVendorParameters, manifest types/enums) for Node-side tooling that must not import the browser runtime.
Changed
ManifestServicenow delegates manifest validation to the shared pureparseBreakManifest(single source of truth, reused by the stitcher). No behavior change.
[0.12.0] - 2026-06-12
Added
- Configurable ad-break-cut safety margin (
adBreakCutSafetyMarginSec, default 2s) across all three SDKs. A break is now always hard-cut back to content ateffectiveDuration + marginat the latest, regardless of whether the inserted ad media has reached its own end — the SDK never depends on the ad stream finishing before returning to content. The small margin grants an ad whose length is close to the break duration a little grace to finish cleanly; set it to0to cut exactly at the boundary. Threaded end-to-end through the webAdPlayerController, the AndroidOverlayAdRenderer, and the iOSOverlayAdRenderer(the iOS renderer previously had no break-cut timer and could run past the break). Added toDolbyAdsConfigin TS/Kotlin/Swift with matching defaults. - The native Android E2E harness is now runnable.
android/dolbyads-demogains anandroidTestinstrumentation suite (ScenarioInstrumentedTest) that loads the sharede2e/scenarios/*.jsonfixtures (bundled as test assets), activates each scenario on the mock backend (10.0.2.2:4500), launchesMainActivityvia the Intent-extra launch hook (auto-boot), and asserts the ordered SDK event lifecycle + overlay rendering on a booted emulator — the Android mirror of the web Playwright runner. Selected by tier via-Pe2eTier=<minimal|extended|full>(+-Pe2eGam=1opt-in). Extended (6) and full (8, GAM skipped) tiers pass green. - The native iOS E2E harness is now runnable. A new
DolbyAdsE2EXCUITest target + shared scheme inios/DolbyAdsDemo/DolbyAdsDemo.xcodeproj(sources inios/DolbyAdsDemo/DolbyAdsE2E/, scenarios bundled as a folder reference) drives the iOS SDK (AVPlayer) through the demo.DolbyAdsDemogains aProcessInfo-launch-environment hook (bootIfRequested(), the iOS counterpart of the web query params / Android Intent extras) and exposes the event log as a singlee2e-event-logaccessibility element;ScenarioUITests.testScenarios()activates each scenario on the mock (localhost:4500), launches the app, and asserts the ordered lifecycle. Tier/GAM/live-content are the scheme's Test-action env vars (E2E_TIERdefaultextended). Extended tier (6 scenarios) passes green on a simulator.
Changed
- Native manifest sources now honor the manifest's
pollingcadence (cross-core parity fix). The AndroidHttpManifestSourceand iOSURLSessionManifestSourcepreviously ignored the manifest'spollingblock and always polled on a fixed default (30s idle), so a late-added break could be observed too late and skipped — diverging from the webManifestService, which honorsmanifest.polling.polling({ idle, active }seconds) is now parsed into the portableBreakManifestbrain in both the Kotlin (dolbyads-core) and Swift (DolbyAdsCore) cores and applied by their manifest sources (currentManifest?.polling ?? default), matching the web. Locked by a new conformance fixture (manifest-polling) —npm run conformance72/72 across TS/Kotlin/Swift. - E2E ad + content media are now served locally by the mock backend. The default video ad (
/media/ad.mp4, a short bundled clip) and the PTS VOD content used by the Android harness (/media/content.mp4) are served by the mock itself instead of depending on a flaky/expiring external CDN, so native playback becomes ready quickly and reliably on emulators. The mock's/media/*route now serves binary assets (extension-based content type); override the ad withE2E_AD_HLS. - E2E wallclock/tune-in scenarios now run out of the box. The web driver defaults
E2E_LIVE_CONTENT_HLSto the demo's live Content URL (which exposesEXT-X-PROGRAM-DATE-TIME) instead of skipping when the env var is unset; set the var to use your own stream, or clear it to skip.
[0.11.0] - 2026-06-12
Added
- Cross-platform E2E suite with a mock manifest backend (
e2e/). A new standalone harness (not an npm workspace, so its Playwright/Express toolchain stays out of the SDK packages) that exercises a real player + ad insertion + on-screen rendering against a deterministic mock backend instead of a live ad server. The same scenario fixtures (e2e/scenarios/*.json) drive every runtime so break behaviour is asserted identically across platforms. The mock (e2e/mock-server, Express + a pure, unit-tested scenario engine) serves a spec-compliant break manifest atGET /:orgId/channels/:channelIdcomputed from the active scenario + elapsed session time, with a control API (POST /__control/session,GET /__control/scenarios|health) and bundled companion/backdrop media. Scenarios are tiered (minimal→extended→full) and cover single-replace, the four extra formats (double, lshape-ad, lshape-content, overlay), tune-in (wallclock join-in-progress), late-addition, and an opt-in GAM real-DAI break. The web driver (Playwright) runs every applicable scenario across HLS.js / Shaka / THEOplayer, asserting the ordered SDK event lifecycle (adbreakbegin → adbegin → adbreakend) plus rendering (ad container visible, companion present, ad video advances). The Android (ExoPlayer) and iOS (AVPlayer) harnesses are scaffolded with a documented launch-hook + run contract (e2e/android/README.md,e2e/ios/README.md) consuming the same scenarios and mock. Selection is interactive via the new/run-e2eworkflow (mapped toE2E_TIER/E2E_ADAPTERS/E2E_GAMenv, no CLI flags). The legacypackages/e2eworkspace was retired; root scriptse2e:web[:headed|:ui],e2e:mock, andtest:e2e*now point ate2e/. - E2E launch hook in the web demo.
packages/demonow reads optional launch config from URL query params (autoboot,player,orgId,manifestBaseUrl,channelId,contentUrl,gam,adInsertion); with no params the demo behaves exactly as before. This is the web equivalent of the planned native launch hooks and lets the E2E driver configure the demo deterministically.
[0.10.0] - 2026-06-12
Added
- Tune-in (join-in-progress) support across all three cores. When a viewer joins a live stream while an ad break is already in progress — or seeks into a break — the SDK now presents that break for its remaining duration instead of skipping it (the previous behaviour, which left the join-in-progress window unmonetised). A new
tuneInconfig option ({ enabled?: boolean; minBreakDurationSeconds?: number }, default{ enabled: true, minBreakDurationSeconds: 5 }) gates this: an in-progress break is only triggered when at leastminBreakDurationSecondsremain, so no sub-minimum sliver of an ad is shown. The remaining duration is threaded end-to-end — the break-cut timer ends the break at the real boundary, and the GAM pod request is built for the remaining duration so the ad server returns a pod that fits the time left. Theadbreakbeginevent gains an optionaltuneIn: { elapsedSec, remainingSec }payload (web object / KotlinTuneInInfo/ SwiftTuneInInfo), andexportDiagnostics()reports the resolvedtuneInconfig. Implemented identically in TS (@dolby-ads/core), Kotlin (dolbyads-core/-sdk/-runtime), and Swift (DolbyAdsCore/SDK/Runtime) and locked by two new conformance fixtures (tune-in-join-in-progress,tune-in-too-late-skipped) —npm run conformance69/69 across all cores.
[0.9.0] - 2026-06-12
Added
- Android TV demo (
android/dolbyads-tv-demo). A newcom.android.applicationmodule proves the unchanged:dolbyads-runtime+:dolbyads-sdkrun on Android TV: it reuses the exact phone-demo wiring (contentExoPlayerviaExoPlayerAdapter→DolbyAdsorchestrator withOverlayAdRenderer/HttpManifestSource/CoroutineSchedulerTicker+ live event log) behind a TV-specific shell — aLEANBACK_LAUNCHERentry,uses-featureleanback/touchscreen(not required), a landscape D-pad-navigable layout with initial focus on Start, and a dark TV theme. Overlay insertion is used on TV (no shared-element)../gradlew :dolbyads-tv-demo:assembleDebug→ BUILD SUCCESSFUL; live ad playback (Media3/IMA) needs a device/emulator. Internal/native tooling — no change to any shipping web package. - Cross-language
PlayerAdapterconformance kits (Kotlin + Swift). Ported@dolby-ads/adapter-test-kitto the native cores soExoPlayerAdapterandAVPlayerAdapterare held to the same contract as the web adapters. Newandroid/adapter-test-kitexposes an abstract JUnit basePlayerAdapterConformanceTest(overridecreateAdapter()); newios/adapter-test-kitexposes an openXCTestCasePlayerAdapterConformanceTestCase(overridemakeAdapter()).ExoPlayerAdapterTestandAVPlayerAdapterTestsnow subclass them. Both assert the universal subset (state properties, volume clamping, mute,programDateTime, subscription lifecycle, optional capabilities); event delivery is asserted where the platform can synthesize a player event headlessly (Swift firesvolumechangevia AVPlayer KVO; ExoPlayer events need real playback, so Android delivery is skipped viaAssumeand covered by on-device integration). Theplayeradapter.contract.mdreference now documents the native kits. Internal/native tooling + test — no change to any shipping web package's public API. - Verified
@dolby-ads/mcpconsumes native diagnostic reports + native JSON export. The native SDKs gained a JSON serializer forexportDiagnostics()— KotlinDiagnosticReport.toJson()(DiagnosticsJson.kt) and SwiftDiagnosticReport.toJSONString()(DiagnosticsJson.swift) — that emits the same report schema as the web SDK (nestedconfig.chaining, lowercaselevel/category). Genuine reports captured from a real Kotlin and Swift session (the newDiagnosticsExportTest/DiagnosticsExportTests, run withWRITE_FIXTURE=1, timestamps normalized) are committed aspackages/mcp/src/__tests__/fixtures/native-{android,ios}.json, and a new MCP test (nativeReports.test.ts) proves the troubleshooting tools (analyze_diagnostics,explain_event_timeline,lookup_error_code) work on them — so the same AI/MCP tooling now demonstrably works on every platform. Internal/native tooling + test — no change to any shipping web package's public API. - Native AI-assistant artifact pointers. Added
android/ai/README.mdandios/ai/README.mdmapping the sharedpackages/sdk/ai/*reference docs to each native platform (theDA-*taxonomy andPlayerAdaptercontract are identical across cores), since native SDKs are not distributed via npm and have nodolby-ads-init-aiequivalent. Cross-linked fromandroid/README.md,ios/README.md, andai/reference/native-overview.md.
Changed
- Single-sourced the diagnostic taxonomy across TS/Kotlin/Swift with a cross-language drift guard. The stable
DA-*diagnostic codes (category/level/summary) were previously hand-maintained in three places (packages/core/.../errorCodes.ts,android/.../Diagnostics.kt,ios/.../Diagnostics.swift). They now derive from one language-neutral source of truth,taxonomy/diagnostic-codes.json, via a generator (taxonomy/gen.mjs,npm run gen:taxonomy) that emits the per-language catalogs (codes.generated.ts,DiagnosticCodes.kt,DiagnosticCodes.swift) plusSDK_VERSIONstamped from the lockstep version. A drift guard (npm run taxonomy:check) fails if any committed catalog is stale, and a new core unit test asserts the TS catalog + version match the source. The@dolby-ads/mcpsnapshot andai/reference/error-codes.mdcontinue to derive from the built core (now itself generated), so they stay single-sourced. Fixes a latent bug: the diagnosticSDK_VERSIONwas stale (0.4.0on web,0.7.0on native) and is now correctly stamped (0.8.4). No codes changed; behavior is unchanged. The/cut-versionand/run-testsworkflows were updated to regenerate/verify the taxonomy.
[0.8.4] - 2026-06-12
Documentation
- Added a platform switcher to the demo docs and decluttered the sidebar. A Web / Android / iOS segmented control at the top of the docs sidebar now scopes the page to one SDK: it shows only the selected platform's SDK nav group and standalone sections (Getting Started, Player Adapters) and, inside the shared How-To pages, reveals only that platform's subsection. The 10 per-SDK How-To sub-links and the two inactive SDK groups are no longer shown at once, cutting visible sidebar entries roughly in half. The How-To pages were restructured so each topic's web body is a
*-websubsection alongside the existing*-android/*-iosones, while genuinely shared references (the Events table and the Diagnostic codes table) stay visible on every platform. The selected platform persists vialocalStorageand is inferred from-android/-iosdeep-link hashes; scroll-spy ignores hidden links and maps per-platform subsections back to their How-To topic. Demo docs only — no code or API change.
[0.8.3] - 2026-06-12
Documentation
- Restructured the demo docs around per-SDK sections and made the How-To pages multi-SDK. The sidebar now flows Overview → Web SDK → Android SDK (Kotlin) → iOS / tvOS SDK (Swift) → How-To (all SDKs) → Concepts & Integrations → Tooling → Changelog, with the three platform SDKs grouped consecutively. Each How-To page (SDK Configuration, Session Management, Events, Diagnostics, Custom Player UI) now carries dedicated Android (Kotlin) and iOS / tvOS (Swift) subsections — with verified
DolbyAdsConfig/SessionConfig/GamConfigtables,startSession/endSession/updateAdTagParameters/play/pause/seeksignatures,addEventListeneridioms + event payloads,onDiagnostic/exportDiagnostics+DiagnosticEventshapes (shared codes), and overlay-container/custom-UI guidance — so each topic documents all three SDKs. Markdown sections were renumbered so the on-page scroll order matches the sidebar exactly; section ids/anchors are unchanged. Demo docs only — no code or API change.
[0.8.2] - 2026-06-12
Documentation
- Reorganized the demo docs navigation into a logical, scroll-consistent order. The docs sidebar is now grouped per SDK — Web SDK (Getting Started → Player Adapters → Configuration → Session → Events → Diagnostics → Custom Player UI), Concepts & Integrations (Ad Formats, Break Manifest, Google Ad Manager), Tooling (AI Assistant), then Android and iOS/tvOS — so each SDK's getting-started and adapters sit together. The markdown sections were renumbered so the on-page (scroll) order matches the sidebar order exactly; section ids/anchors are unchanged.
[0.8.1] - 2026-06-12
Documentation
- Developer-facing native onboarding in the demo docs. Replaced the single internal-leaning "Native SDKs (Android/iOS)" page with two developer-facing platform pages mirroring the web Adapters layout — Android (Kotlin) (
packages/demo/docs/13-android.md) and iOS / tvOS (Swift) (packages/demo/docs/14-ios.md) — each with Getting Started (preview install coordinates + a runnable quick start, incl. a GAM variant), the existing runtime adapter (ExoPlayerAdapter/AVPlayerAdapter), the fullPlayerAdapterinterface, and a custom-adapter guide. Internal content (conformance harness, repo layout, parity story) was removed from the page; the sidebar now has dedicated Android and iOS groups. Demo docs only — no code or API change.
[0.8.0] - 2026-06-12
Documentation
- Docs + AI enablement synced with the native platforms. Brought every developer-facing surface up to date with the now-complete Android + iOS/tvOS SDKs: a new cross-platform
docs/architecture.md(layer model, seam map, parity story, and a package/component naming assessment); per-platformandroid/README.md+ios/README.md; a "Repository guide — where to find what" section inREADME.md(with native module tables) and refreshed status (both insertion phases shipped on web/Android/iOS, replacing the stale "MVP Phases"); a new Native SDKs (Android/iOS) page in the web demo docs (packages/demo/docs/13-native-sdks.md+ nav link, cross-linked from Overview/GAM). Contributor guidance updated: the/run-tests,/core-parity, and/cut-versionworkflows andAGENTS.mdno longer hedge native as "when the packages exist", and/sync-docsgained native-doc mapping rows. Shipped integrator AI artifacts gained a concise, clearly-scopedai/reference/native-overview.md(auto-included bydolby-ads-init-ai, drift-guarded by a test, listed in the AI Assistant docs, and referenced by the onboarding agent). Naming note: the Android runtime module/package were subsequently renameddolbyads-android/com.dolby.ads.android→dolbyads-runtime/com.dolby.ads.runtimefor cross-platform symmetry — see the Changed entry below.
Changed
- Renamed the Android runtime module + package for cross-platform symmetry. The Gradle module
:dolbyads-android→:dolbyads-runtimeand the Kotlin packagecom.dolby.ads.android→com.dolby.ads.runtime, so the Android layer packages are now a consistent sibling set (com.dolby.ads.core/.sdk/.runtime) mirroring iOSDolbyAdsCore/SDK/Runtime. Updatedsettings.gradle.kts, the module/demobuild.gradle.kts(namespace+project(":dolbyads-runtime")dependency), allpackage/importstatements (3 runtime sources + the test + the demo'sMainActivity), and every doc reference. The Gradle build's root project name staysdolby-ads-android(it names the workspace, not the module). Pure rename — no behavior change, conformance unaffected. Internal/native tooling — no change to any shipping web package.
Added
- iOS demo app (
ios/DolbyAdsDemo, P5c) — a SwiftUI app mirroring the web/Android demos. ADemoController(ObservableObject) wires a contentAVPlayer(viaAVPlayerAdapter) into a per-sessionDolbyAdsinstance built from the default Foundation seams (URLSessionManifestSource+DispatchSchedulerTicker) and theOverlayAdRenderer, rendering content + the ad overlay in a singleAVPlayerLayer-backedUIView. The UI exposes org/channel/content-URL/GAM-network/custom-asset-key inputs, Start/End/Diagnostics controls, and a live log that subscribes to everyDolbyAdsEventType+ the diagnostic stream and can dump a redactedexportDiagnostics()report. To keep integration to a single import,DolbyAdsRuntimenow@_exported importsDolbyAdsCore+DolbyAdsSDK, so the app wires everything with justimport DolbyAdsRuntime. Builds via a committed.xcodeprojreferencing the local Swift package:xcodebuild -scheme DolbyAdsDemo -destination 'id=<iPhone 16 sim>' build→ BUILD SUCCEEDED. Internal/native tooling — no change to any shipping web package. - iOS runtime unit tests (
ios/DolbyAdsRuntime, P5b-3) — the Swift mirror of Android'sExoPlayerAdapterTest. A newDolbyAdsRuntimeTestsXCTest target addsAVPlayerAdapterTests, which runs on the iOS simulator (realAVPlayer, no device/IMA needed) and verifies theAVPlayerAdapter's portablePlayerAdaptermapping: volume pass-through + clamping to[0, 1], mute pass-through, unset-duration →+Infinity, the initial paused/zero/no-PDT state, andvolumechangeevent subscribe/unsubscribe via the token-basedon/off. Verified withxcodebuild test -scheme DolbyAdsRuntime -destination 'id=<iPhone 16 sim>'→ TEST SUCCEEDED (5/5). Internal/native tooling — no change to any shipping web package. - GAM/IMA DAI pod serving on Apple (
ios/DolbyAdsRuntime, P5b-2b) — completes parity with the AndroidP4b-2b. A newGamStreamManagerwraps the Google IMA SDK for iOS (GoogleInteractiveMediaAds3.32.0, added via Swift Package Manager): it requests a DAIIMAPodStreamRequest, exposes the resultingstreamId, forwards ad-break quartile/error events, and supportsreplaceAdTagParameters. Unlike Android, the adAVPlayeris wrapped in anIMAAVPlayerVideoDisplay, so IMA reads the pod's ID3 timed metadata automatically — no manual metadata forwarding is needed.OverlayAdRenderernow accepts an optionalGamConfig: when supplied,isGamEnabled()is true,startGamSessioninitializes the IMA session per monetization session, and GAMvendorassets resolve to freshly built pod-manifest URLs via the portable, conformance-lockedbuildGamPodUrl+ the live stream id (IMA quartiles are tagged with the on-screen break/asset and forwarded to the SDK). The pod-URL builder itself remains the shared core unit pinned across TS/Kotlin/Swift by the conformance harness. Because the IMA SDK requires iOS/tvOS 15+, the runtime module's minimum deployment target was raised to 15. Verified withxcodebuild -scheme DolbyAdsRuntime -destination 'generic/platform=iOS Simulator' build→ BUILD SUCCEEDED (IMA xcframework linked). The IMA/AVPlayer paths need a device/simulator to exercise at runtime; everything verifiable headlessly (compile + link against the real IMA SDK) is green. Internal/native tooling — no change to any shipping web package. - iOS/tvOS native runtime module (
ios/DolbyAdsRuntime, P5b) — the first Apple-native runtime layer, the Swift mirror ofandroid/dolbyads-runtime.AVPlayerAdapterimplements the portablePlayerAdapterover an integrator-ownedAVPlayer(current time/duration/paused/muted/volume,programDateTimefromAVPlayerItem.currentDate()for HLS PDT matching, pause/play/seek/load, and KVO + a periodic time observer + item notifications fanned out to the SDK'sPlayerAdapterEvents with token-basedon/off).OverlayAdRendererimplements theAdRendererseam — playing break assets on a dedicated adAVPlayerin anAVPlayerLayeroverlaid above the integrator's content surface (content paused), with the public ad-event order driven entirely by the conformance-verified coreAdBreakSequencer(overlay single/multi-asset, thelshape_contentbackdrop, the asset-error path, and consecutive-break chaining). UIKit/AVPlayer work is marshalled to the main actor. Scope is static (direct-URL) overlay insertion; GAM/IMA DAI pod serving is the next sub-phase (isGamEnabled()is currently false). Because UIKit/AVKit are iOS/tvOS-only, this target builds viaxcodebuildagainst an iOS destination rather thanswift buildon macOS — verified withxcodebuild -scheme DolbyAdsRuntime -destination 'generic/platform=iOS Simulator' build→ BUILD SUCCEEDED (arm64 + x86_64, iOS 26.2 SDK). Internal/native tooling — no change to any shipping web package. - iOS/tvOS SDK orchestrator + runtime seams (
ios/DolbyAdsSDK, P5a). A new pure-Swift/Foundation SwiftPM package (no AVKit/UIKit/IMA) mirrors the Androiddolbyads-sdk: aDolbyAdsorchestrator that wires the portableDolbyAdsCorebrain to aPlayerAdapterand emits the public SDK event stream (adbreakbegin→adbegin/adend/aderror→adbreakend, GAM quartiles,waiting/playing), with the same session lifecycle, overlapping-break suppression, snapback/seek gating, ad-tag-parameter merge, and redactedexportDiagnostics()report as the TS/Kotlin references. Platform work is injected behindAdRenderer,ManifestSource, andSchedulerTickerprotocols; two default Foundation seams are included —URLSessionManifestSource(URLSession fetch +DispatchSourceTimerpoll, validated through the conformance-locked coreManifestService) andDispatchSchedulerTicker(drives the timer-less coreBreakScheduler.tick()). Async is Swift concurrency (startSession/playBreak/preload). To support the orchestrator, the Swift core was brought to full parity with Kotlin:PlayerAdapterexpanded to the complete contract (duration/paused/muted/volume/programDateTime/pause/play/seek/load/events/destroy, token-basedon/off), theBreakmodel gainedBreakFormat/Asset/BreakVariant/Controls+resumeOffset/controls/variants(lenient parse), andBreakSchedulergainedstop().swift test(6 tests: orchestrator wiring + seams) andnpm run conformance(63/63) are green. The AVPlayer/UIKit + IMA DAI runtime is the next sub-phase. Internal/native tooling — no change to any shipping web package. - Android demo app + runtime tests (
android/dolbyads-demo, P4b-2/P4b-3). A newcom.android.applicationmodule mirrors the web demo: a singleMainActivitywires a content ExoPlayer (viaExoPlayerAdapter) into theDolbyAdsorchestrator with theOverlayAdRenderer,HttpManifestSource, andCoroutineSchedulerTicker, and surfaces a live SDK event log (subscribing to everyDolbyAdsEventType+ the diagnostic stream) plus a redactedexportDiagnostics()dump, with org/channel/content-URL inputs and start/end/diagnostics controls../gradlew :dolbyads-demo:assembleDebugproduces a runnable debug APK. The Android runtime now also has a Robolectric unit-test harness:ExoPlayerAdapterTestverifies the Media3 adapter's property mapping (mute/volume preservation + clamping, unset-duration →+Infinity, initial paused state, event subscribe/unsubscribe) on the JVM without a device. Internal/native tooling — no change to any shipping web package. - GAM/IMA DAI pod serving on Android (P4b-2b). The pod-manifest URL builder is now a portable core unit, locked across all three brains:
buildGamPodUrl+GamVendorParameters/parseGamVendorParameters/isGamVendorParameterswere ported to the Kotlin (android/dolbyads-core) and Swift (ios/DolbyAdsCore) cores (mirroring the webGamPodUrlBuilder), and three new conformance fixtures (gam-pod-url-v1/-v2/-invalid) pin the EABN V1/pod/vs V2/ad_break_id/paths, the flooredpd(ms), and rejection of malformed vendor params byte-for-byte across TS/Kotlin/Swift (npm run conformance→ 63/63). On Android, a newGamStreamManager(android/dolbyads-runtime) wraps the Google IMA SDK (com.google.ads.interactivemedia.v3:interactivemedia:3.35.1): it requests a DAIPodStreamRequest, exposes the resultingstreamId, forwards ad-break/quartile/error events, supportsreplaceAdTagParameters, and relays HLS ID3 timed metadata to IMA.OverlayAdRendereris now GAM-aware — aGamConfigenablesisGamEnabled(),startGamSession()initializes the IMA session, andvendor: "gam"assets resolve to freshly built pod URLs (via the live stream id) and play on the ad player, with IMA quartiles forwarded to the SDK event stream. The portableAssetgained optionalvendor/vendorParameters(parsed leniently, conformance-neutral). Runtime IMA behavior requires a device/emulator;./gradlew build(all modules + Android lint) and the Kotlin core pod-URL unit tests are green. Internal/native tooling — no change to any shipping web package. - Android runtime module (
android/dolbyads-runtime, P4b-2) — the first Android-native runtime layer:ExoPlayerAdapterimplements the portablePlayerAdapterover Media3/ExoPlayer (position/duration/paused/muted/volume,programDateTimefrom the live window's wallclock anchor for HLS PDT matching, pause/play/seek/load, and a singlePlayer.Listenerfanned out to the SDK'sPlayerAdapterEvents), andOverlayAdRendererimplements theAdRendererseam — playing break assets on a dedicated adExoPlayerin aPlayerViewoverlaid above the integrator's content surface (content paused), with the public ad-event order driven entirely by the conformance-verified coreAdBreakSequencer(overlay single/multi-asset, thelshape_contentbackdrop, the asset-error path, and consecutive-break chaining). All ExoPlayer interaction is marshalled toDispatchers.Main. Scope is static (direct-URL) insertion; GAM/IMA DAI pod serving is the next sub-phase (isGamEnabled()is currentlyfalse). The portableAssetgained optionaluri/mediaType(parsed leniently, conformance-neutral) so the renderer can load static assets. Toolchain: a newcom.android.librarymodule on AGP 8.7.3 + Media3 1.4.1 (compileSdk 34,minSdk 21); since the Android Gradle Plugin needs JDK 17 and does not support Gradle 9.x, the Android workspace now runs Gradle 8.14.4 on JDK 17 (pinned viagradle.properties), withgoogle()repos and a gitignoredlocal.properties../gradlew build(all three modules + Android lint) andnpm run conformance(54/54) are green. Internal/native tooling — no change to any shipping web package. - Kotlin runtime seams (
android/dolbyads-sdk, P4b-1) — the two default, pure-Kotlin/JVM (no Android) implementations of the orchestrator's platform seams, reusable across Android phone/TV:CoroutineSchedulerTickerdrives the timer-less coreBreakScheduler.tick()from a coroutinedelayloop (default 250 ms, mirroring the websetInterval), andHttpManifestSourcefetches the manifest over HTTP and validates it through the conformance-locked coreManifestService. The fetch is injected via aManifestFetcherfun-interface (defaultOkHttpManifestFetcher, blocking call onDispatchers.IO) so it unit-tests without networking; JSON is decoded withkotlinx-serialization-jsoninto the plainAny?/Map/Listtree the parser expects (insertion order preserved). Polling matches the web reference (idle 30 s / active 5 s, interval chosen atstartPolling,setActiverecords the flag only). JVM tests cover ticker cadence/stop, decode+validate, missing-URL/invalid-manifest errors, and poll update/error/teardown. The Android-specific runtime (Media3PlayerAdapter, overlayAdRenderer, IMA DAI) and the demo app follow in P4b-2/P4b-3. Internal/native tooling — no change to any shipping web package. - Kotlin SDK orchestrator (
android/dolbyads-sdk, P4a-3) — the player-agnostic Android mirror of the webDolbyAdsorchestrator (pure Kotlin/JVM, no Media3/IMA/networking). It wires the portable:dolbyads-corebrain to aPlayerAdapterand emits the public SDK event stream (adbreakbegin→adbegin/adend/aderror→adbreakend, plus GAM quartiles andwaiting/playing), enforcing the same session lifecycle, overlapping-break suppression, snapback/seek gating, ad-tag-parameter merge, and redacted diagnostics timeline/exportDiagnostics()report as the TS reference. Platform-specific work is injected behind three seams so the orchestrator stays portable:AdRenderer(ad rendering + GAM, the webAdPlayerController's role),ManifestSource(network fetch + poll; the parse/validate half stays in the conformance-locked coreManifestService), andSchedulerTicker(drives the timer-less coreBreakScheduler.tick(), mirroring the web 250 ms interval). The portableBreakmodel gained optionalvariant/controls/resumeOffset(parsed leniently, forward-compatible) and the coreBreakSchedulergained astop()to match the TS lifecycle. Async is coroutine-based (startSession/playBreak/preloadsuspend). JVM unit tests (./gradlew :dolbyads-sdk:test) cover the wiring; intra-break event ordering remains pinned by the conformance harness (npm run conformance→ 54/54). The Android runtime renderer (Media3 ad player + overlay + IMA DAI) and a realManifestSource/SchedulerTickerland in P4b. Internal/native tooling — no change to any shipping web package. - Portable
AdBreakSequencerin@dolby-ads/core(P4a-2) — the single, DOM-free source of truth for the order of the SDK's public ad events:adbreakbegin→ per assetadbegin/adend(oraderror) →adbreakend, including consecutive-break chaining (overlay only), the shared-element single-fullscreen downgrade, and thelshape_contentbackdrop (no per-ad events). It owns sequencing only (no I/O, no DOM/IMA, no timers): a driver performs the side effects (play asset, render backdrop, end break) and reports outcomes back, so the observable event contract can be verified across the TS/Kotlin/Swift cores by the conformance harness. ExposesAdBreakSequencer,selectVariant,extractAssets, and theEffectiveInsertionMode/AdBreakStep/AdBreakAction/AdBreakOutcome/AdBreakTransition/ChainResolvertypes. - SDK ad-event conformance across all three cores (P4a-2). The conformance harness now verifies the SDK event order (not just brain scheduling): the TS CLI drives the
AdBreakSequencerand emits a normalizedsdkSequence, and theAdBreakSequenceris mirrored in the Kotlin (android/dolbyads-core) and Swift (ios/DolbyAdsCore) cores. Seven new fixtures cover overlay single/multi-asset, the asset-error path (aderror→adend, noadbegin), thelshape_contentbackdrop, overlay chaining, and the shared-element single-fullscreen downgrade +lshape_contentskip. All three cores pass every fixture (npm run conformance→ 54/54). Internal/native tooling — no change to any shipping web package. - Web
AdPlayerControllernow delegates to the sharedAdBreakSequencer(P4a-2b). Variant/asset selection (getVariant/getAssets) and the intra-break ad-event ordering (theadbegin/adend/aderrorsequence and the next-asset-vs-finalize decision) are driven by the conformance-verified sequencer instead of duplicated inline logic, so the web SDK and the native cores share one source of truth and cannot drift. Break-level steps (adbreakbegin/adbreakend), consecutive-break chaining, thelshape_contentbackdrop, shared-element, and all DOM/IMA remain owned by their existing paths. Behavior is unchanged — allAdPlayerControllersuites (96 tests) stay green. - Android Gradle build for the Kotlin brain core (P4a-1). The native Kotlin workspace (
android/) now has a real Gradle build — a committed Gradle wrapper (android/gradlew,gradle/wrapper/),settings.gradle.kts(rootdolby-ads-android, module:dolbyads-core), a root build script, anddolbyads-core/build.gradle.kts(Kotlin/JVM library, JVM target 17)../gradlew :dolbyads-core:buildcompiles the brain and runs JVM smoke tests (kotlin("test")+ JUnit Platform); the exhaustive parity checks still live in the conformance harness, which continues to compile the samesrc/main/kotlinsources viakotlinc(npm run conformance→ 33/33 still green). Gradle/.gradle/buildartifacts are gitignored. Internal/native tooling — no change to any shipping web package. - Swift brain core (
ios/DolbyAdsCore/) — the second native core, a pure-Swift/Foundation mirror of the portable brain (no UIKit/AVKit):BreakManifesttypes,ManifestServicevalidation (error messages identical to TS/Kotlin),BreakScheduler(scheduling, PTS/wallclock timebase via an injectableClock+tick()seam, chaining, overlapping-break suppression, preload-approaching), and preload/insertion mode resolution. Driven by a SwiftPM CLI core (conformance/adapters/swift/) that reproduces the TS goldens byte-for-byte — all three cores now pass every fixture (npm run conformance→ 33/33). Built withswift build(Xcode/SwiftPM); insertion order preserved via an ordered array (JSMapparity) andjsRound/ISO-8601 parsing matched to the reference. Internal/native tooling — no change to any shipping web package. - Kotlin brain core (
android/dolbyads-core/) — the first native core, a pure-JVM Kotlin mirror of the TS portable brain (no Android dependencies):BreakManifesttypes,ManifestServicevalidation (error messages identical to TS),BreakScheduler(scheduling, PTS/wallclock timebase via an injectableClock+tick()seam, consecutive-break chaining, overlapping-break suppression, preload-approaching), and preload/insertion mode resolution. Verified against the TS reference by the conformance harness via a Kotlin CLI core (conformance/adapters/kotlin/) — both cores now pass all fixtures (npm run conformance→ 22/22). The harness was hardened to gracefully skip a core whose toolchain is absent (so the Swift gap and toolchain-less machines don't fail the run) and to support a per-corepathPrepend/env(the Kotlin core targets a Homebrewkotlinc+ modern JDK). Internal/native tooling — no change to any shipping web package.
[0.7.0] - 2026-06-11
Added
- AI onboarding/training agent + quickstart bootstrapper. New shipped AI artifacts in
@dolby-ads/sdk: adolby-onboardingagent (ai/agents/dolby-onboarding/AGENT.md) that tutors a newcomer through the mental model (content player +PlayerAdapter, the SDK-managed ad overlay, manifest +programDateTimetimebase, the session lifecycle, and the diagnostics timeline), shows where to start, then offers to try it; and adolby-quickstart-demoskill (ai/skills/dolby-quickstart-demo/SKILL.md) that collects a few inputs and bootstraps a runnable demo. Both ship viadolby-ads-init-ai. A newscaffold_quickstarttool in@dolby-ads/mcp(also exported as the purescaffoldQuickstart/normalizeQuickstartInputfunctions) turns{ orgId, channelId, player, contentUrl, environment?, gam? }into a self-contained single-fileindex.htmldemo (ESM CDN imports, no build step) plus notes/warnings, supporting HLS.js, Shaka, THEOplayer, native<video>, and a custom-adapter stub. The demo app surfaces this as a Get started card (on the home page and the AI Assistant docs page) that runsscaffold_quickstartfully client-side. - Scheduler determinism seam (groundwork for the cross-language conformance harness).
BreakSchedulernow takes an optional injectableClock(defaultsystemClock, backed byDate.now()) used for the wallclock timebase, and exposes a publictick()entry that the internal poll interval delegates to — so a driver can step time deterministically without real timers.ClockandsystemClockare exported from@dolby-ads/core. Behavior is unchanged when no clock is injected. - Cross-language brain conformance harness (
conformance/) — the source of truth for keeping the three core implementations (TS / Kotlin / Swift) behaviorally identical. Language-neutral fixtures (conformance/fixtures/<name>/input.json) describe a config + manifest + simulated timeline; the TS reference CLI (conformance/adapters/ts/) drives the portable brain deterministically (via theClock/tick()seam) and emits a normalized result, pinned as a committed golden (expected.json). The orchestrator (conformance/run.mjs, scriptsnpm run conformance/npm run conformance:update) builds and runs every available core CLI against each fixture and diffs against the golden. Cores are discovered via acore.jsonperconformance/adapters/<core>/, so adding the Kotlin/Swift cores later is drop-in. Initial fixtures cover manifest validation (incl. forward-compat), PTS/wallclock scheduling, chaining, overlap suppression, preload-approaching, and preload/insertion mode resolution. Rendering / real-player / IMA remain out of scope (covered per platform by Playwright/Espresso/XCUITest). Dev-tooling only — no change to any shipping package.
[0.6.0] - 2026-06-10
Added
- iPhone iOS 17.1+ feature parity via Managed Media Source (MMS) — a new
adaptivead-insertion mode. On iPhone/iPod,autonow resolves toadaptivewhenManagedMediaSourceis available (iOS 17.1+), so the ad engine runs through HLS.js/MMS instead of the degraded native fallback. Inadaptivemode the SDK uses the full overlay compositor (all break formats:double,lshape_ad,lshape_content,overlay) while the content video is inline, and falls back toshared-element(a single fullscreen ad through the content element) only while the content video is in OS-native fullscreen, where DOM overlays cannot render. Legacy iOS (< 17.1, no MMS) still resolves toshared-element; every other platform stays onoverlay.adInsertion: 'adaptive'can also be set explicitly. adaptiveadded to theAdInsertionMode/ResolvedInsertionModetypes; newdetectMediaSourceCapabilities()helper andMediaSourceCapabilitiestype exported from@dolby-ads/core.resolveInsertionMode()now takes aMediaSourceCapabilitiesobject (native MSE vs MMS) instead of a single boolean.
Notes
- AirPlay: MMS sets
disableRemotePlayback=true, which disables AirPlay — but this only affects the SDK-owned ad element (ads are not AirPlayed). The content element, where AirPlay matters, is integrator-owned; integrators who want AirPlay should append an HLS<source>element to their own content<video>.
[0.5.0] - 2026-06-09
Added
- AI Assistant documentation — a new
AI Assistantdocs page (packages/demo/docs/12-ai-assistant.md, linked from the docs nav) with an "explain like I'm 2" intro and progressive detail on the AI artifacts, thedolby-ads-init-aiCLI, registering the@dolby-ads/mcpserver, and the troubleshooting flow. Cross-referenced from the Overview and the rootREADME.md. - Demo AI Troubleshooting panel — the demo app now has an "AI Troubleshooting" card with three actions: AI Analyze (runs the same
analyzeDiagnosticslogic as@dolby-ads/mcp, fully client-side, and renders the root-cause hint, ranked findings with remediation, and timeline observations), Export Report (shows the redactedexportDiagnostics()JSON with copy/download), and Check Adapter (inspects the livePlayerAdapterfor required/optional contract members). Structured diagnostics are also surfaced live in the event log. dolby-ads-init-aiCLI in@dolby-ads/sdk— scaffolds the SDK's AI artifacts (skill, agent, and references) from the package'sai/folder into a consumer's project so their AI IDE (Claude Code, Windsurf, Copilot + AGENTS.md, etc.) can pick them up. Runnpx dolby-ads-init-ai [targetDir] [--force](defaults the target to./.dolby-ads/ai); existing files are skipped unless--forceis passed. The copy logic is also exported as pure functionsrunInitAi(options)/listFilesRecursive(dir)(withInitAiOptions/InitAiResulttypes) from@dolby-ads/sdk.@dolby-ads/mcp— a Model Context Protocol (stdio) server exposing Dolby Ads troubleshooting tools to AI assistants via thedolby-ads-mcpbinary. Three tools:lookup_error_code(code → category/level/summary/remediation),analyze_diagnostics(severity tallies + ranked findings with remediation + timeline + root-cause hint from anexportDiagnostics()report), andexplain_event_timeline(DA-EVENT sequence + ad-break lifecycle anomalies). Implementsinitialize/tools/list/tools/call/pingover newline-delimited JSON-RPC 2.0. The server is self-contained — it embeds a generated, drift-guarded snapshot of the@dolby-ads/corediagnostic taxonomy and has no runtime dependencies. The tool logic is also exported as pure functions (lookupErrorCode,analyzeDiagnostics,explainEventTimeline).- AI integration artifacts shipped in
@dolby-ads/sdkunderai/: adolby-adapter-integrationskill (ai/skills/.../SKILL.md) for scaffolding/validating aPlayerAdapter, adolby-troubleshooteragent (ai/agents/.../AGENT.md) for diagnosing fromexportDiagnostics()reports, and machine-readable references:playeradapter.contract.md,knowledge-base.md(code → cause → fix), and a generatederror-codes.md.error-codes.mdis generated from the@dolby-ads/coretaxonomy vianpm run gen:ai-docs -w @dolby-ads/sdk, and a drift-guard test fails the build if it falls out of sync. Theai/folder is published with the package. @dolby-ads/adapter-test-kit— a sharedPlayerAdapterconformance suite.runAdapterConformance(name, { createAdapter, capabilities, emit? })registers a standard set of checks (all seven events forward,off()/destroy()cleanup,currentTime/duration/paused/muted/volume/programDateTimeaccessors,seek(), and optionalvideoElement/preload/supportsParallelBufferingcapabilities) so custom adapters can prove they satisfy the contract the SDK core relies on. The official@dolby-ads/adapter-hlsjsand@dolby-ads/adapter-shakaadapters now run this kit.- Structured diagnostics layer on
@dolby-ads/core. A machine-readable diagnostic stream runs alongside the typed events:sdk.onDiagnostic(handler)/offDiagnostic(handler)deliverDiagnosticEvents with a stablecode,category, severitylevel, and JSONcontext.sdk.exportDiagnostics()returns a self-contained, redactedDiagnosticReport(SDK version, redacted config summary, user agent, and the recent diagnostic + event timeline) suitable for support tickets or AI troubleshooting — ad-tag-parameter values are never included. Newdiagnostics?: { bufferSize?: number }config (default200) bounds the in-memory ring buffer. A stable code taxonomy (DIAGNOSTIC_CODES) and theDiagnosticEvent/DiagnosticReport/DiagnosticBuffer/DiagnosticsConfig/KnownDiagnosticCodetypes plusSDK_VERSIONare exported from@dolby-ads/core. Emitted SDK events are mirrored into the timeline under theDA-EVENTcode. - Consecutive-break chaining via the new
chainingconfig option ({ enabled?: boolean; maxGapSeconds?: number }, default{ enabled: true, maxGapSeconds: 2 }). When two breaks are separated by a gap no larger thanmaxGapSeconds, the SDK plays them as one continuous ad sequence: the overlay is held across the gap (content is never resumed and the layout is not torn down) and the next break's first (static) asset is preloaded during the current break (HTTP cache warm + detached prefetch, never a second MediaSource — safe on single-decoder Smart TVs). Eliminates the content flash / spinner between back-to-back breaks (e.g. two breaks with different ad targeting).ChainingConfig/ResolvedChainingtypes,DEFAULT_CHAINING, andresolveChainingare exported from@dolby-ads/core. Not applied inshared-element(iPhone) mode, which always hard-ends.
Fixed
- Overlapping ad breaks are now suppressed: if the scheduler triggers a break while an ad is already playing, the new break is ignored (and marked complete so it is not left stuck
ACTIVE) instead of starting a second ad on top of the first. Protects all insertion modes and removes a mid-ad PTS-timebase mismatch inshared-elementmode.
[0.4.0] - 2026-06-09
Added
adInsertionconfig option ('overlay' \| 'shared-element' \| 'auto', default'auto') enabling iPhone Safari support. On iPhone/iPod (auto) the SDK switches toshared-elementinsertion: a single fullscreen ad is played through the content<video>element so iOS native fullscreen is preserved (DOM overlays cannot render over the OS fullscreen player). Advanced formats (double,lshape_ad,overlay) are downgraded to a single fullscreen ad andlshape_contentis skipped. GAM/IMA DAI is supported in this mode (IMA binds to the content element; pod URLs play on it). Every other platform stays on the overlay compositor.resolveInsertionMode/isSharedElementUserAgenthelpers andResolvedInsertionMode/AdInsertionModetypes exported from@dolby-ads/core.NativeVideoAdapter.programDateTimenow derives PDT from WebKit'sHTMLVideoElement.getStartDate()+currentTime, enabling wallclock break matching on iPhone native HLS (returnsnullwhen unavailable).- New
@dolby-ads/sdkentry package that defaults the ad player to HLS.js (with a native<video>fallback for MSE-less platforms such as Safari/iOS/older tvOS). ImportingDolbyAdsfrom@dolby-ads/sdkmakescreateAdAdapteroptional. adPreloadconfig option ('parallel' | 'single-decoder' | 'auto', default'parallel').single-decoderwarms the HTTP cache and does a detached manifest/fragment prefetch without attaching a second MediaSource — safe for single-decoder Smart TVs (Tizen 3, legacy webOS).autoresolves the mode via User-Agent inspection.- Optional
preload?(url)andsupportsParallelBuffering?members on thePlayerAdapterinterface. Implemented byadapter-hlsjs(detached prefetch via a throwawayHlsinstance withstartFragPrefetch) and the newNativeVideoAdapter. resolvePreloadMode/isSingleDecoderUserAgenthelpers exported from@dolby-ads/core.
Changed
createAdAdapteris now optional inDolbyAdsConfig. The bare@dolby-ads/coreDolbyAdsthrows a clear error if it is omitted (no default); use@dolby-ads/sdkor supply your own factory.
[0.2.0] - 2026-06-09
Added
waitingandplayingplayback events on the SDK, emitted for both content and ad playback. Each carries asource: 'ad' | 'content'field;break/assetare present only whensource === 'ad'.playingfires on every nativeplayingevent.waiting/playingsupport in thePlayerAdapterinterface and all adapters (adapter-hlsjs,adapter-shaka,adapter-theoplayer).
Changed
aderrornow covers both ad and content playback errors, distinguished by a newsource: 'ad' | 'content'field.break/assetare now optional (present only for ad errors). The emittedErrornow includes the underlying media error code/message when available.
[0.1.0]
Added
- Initial project setup with monorepo structure
@dolby-ads/corepackage with PlayerAdapter interface@dolby-ads/adapter-hlsjspackage for HLS.js integration- Break manifest polling and parsing
- Static HLS ad insertion (MVP Phase 1)