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.

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

Architecture

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

Key concepts

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

Getting Started

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

Step 1 — Install

npm install @dolby-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:

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

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

Step 9 — Clean up

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

Custom ad player with @dolby-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,
});
Bare @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.

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

PlayerAdapter Interface

Any adapter must implement all properties and methods below. The interface is imported from @dolby-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 Hls instance for content. The ad HLS instance is created inside createAdAdapter.
  • One <video> element for content. The ad <video> is created by your factory inside the SDK-provided container.
  • HLS streams must contain EXT-X-PROGRAM-DATE-TIME tags for wallclock-timebase channels.

Full setup example

import Hls from 'hls.js';
import { 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.

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

Cleanup

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

Shaka Player Adapter

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

Install

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

Requirements

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

Full setup example

import shaka from 'shaka-player';
import { 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.

PDT is only available for live streams where Shaka has parsed the presentation timeline. It returns 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).

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

The adapter subscribes to three Shaka events:

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

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:

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

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 libraryLocation to serve THEOplayer's worker/WASM files — use a CDN or copy from node_modules/theoplayer/

Key difference — no raw <video> element

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

Full setup example

import { ChromelessPlayer } from 'theoplayer/chromeless';
import { 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.

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

PDT (Program Date Time)

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

Cleanup

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

Native / Safari & iOS (NativeVideoAdapter)

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

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

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

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

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

PDT (Program Date Time)

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

iPhone / iPod: adaptive & shared-element insertion

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

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

See SDK Configuration → Ad insertion behaviour.

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

Implementing a Custom Adapter

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

TypeScript skeleton

import type {
  PlayerAdapter,
  PlayerAdapterEvent,
  PlayerAdapterEventHandler,
} from '@dolby-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);
    });
  }
}
Key implementation notes:
load(url) must resolve only when the player is ready to start playback immediately — not just when the request is sent.
programDateTime must reflect the current stream position, not just the start of the manifest.
• The SDK passes a <div> container to createAdAdapter. Create your own <video> inside it — never share the content player instance or its video element.

Validate your adapter

@dolby-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

Preview — coordinates not yet published. The Maven coordinates below are the intended distribution and are not yet available on a public repository. They are shown so your build wiring matches the final flow.
// 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"))
}
All SDK calls run on the player's application thread — typically the main thread, per ExoPlayer's threading model. startSession is a suspend function: call it from a coroutine. Call sdk.destroy() (then release your ExoPlayer) when tearing down.

Ad formats & overlay rendering

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"))
}
GAM breaks are skipped if no 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) }
    }
}
Key implementation notes:
load(url) and play() return immediately — signal readiness through playing/waiting events, never block.
programDateTime must reflect the current stream position (epoch ms), not just the start of the manifest.
• Never share the content player instance with the ad renderer — the SDK owns its own ad player surface.

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

Preview — coordinates not yet published. The Swift Package Manager and CocoaPods references below are the intended distribution and are not yet available. They are shown so your dependency wiring matches the final flow.
// 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"))
}
GAM breaks are skipped if no 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) }
    }
}
Key implementation notes:
load(_:) and play() return immediately — signal readiness through playing/waiting events, never block.
programDateTime must reflect the current stream position (epoch ms), not just the start of the manifest.
• Never share the content player instance with the ad renderer — the SDK owns its own ad player surface.

SDK Configuration

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

This topic is documented for every SDK. Use the Web / Android / iOS switcher at the top of the sidebar to choose your platform. The config shape is intentionally parallel across platforms (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.
The 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.
Forcing 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.
DOM overlays cannot render over the OS-native fullscreen video player on iPhone/iPod. On iOS 17.1+ (adaptive) the SDK shows rich overlay formats while inline and degrades to a single shared-element ad only in fullscreen; on older iOS it uses shared-element throughout. iPad is intentionally excluded — iPadOS supports element-level fullscreen and MSE, so it stays on overlay.
AirPlay: Managed Media Source sets disableRemotePlayback=true, which disables AirPlay on the SDK-owned ad element (ads are not AirPlayed). To keep AirPlay on your content player, append an HLS <source> element to your own content <video>.

Consecutive-break chaining (chaining)

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

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

  • The ad overlay is held across the gap — content is never resumed and the layout is not torn down (no flash).
  • The next break's first (static) asset is preloaded during the current break (HTTP cache warm + a detached prefetch), so it starts without a spinner. This never attaches a second MediaSource, so it is safe on single-decoder Smart TVs.
  • Each break still emits its own adbreakbegin / adbreakend events and resolves its own targeting, so analytics and per-break ad requests are unchanged.
Option Default Description
enabled true Whether chaining is active. false restores per-break teardown (content resume + layout reset between breaks).
maxGapSeconds 2 Maximum gap between one break's end and the next break's start for them to chain. 0 effectively disables chaining.
Chaining holds the overlay across the gap, so on a live stream the content stays paused for the gap plus both breaks and falls further behind the live edge. Chaining is not applied in shared-element (iPhone) mode, where breaks always hard-end.
const sdk = new 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 minBreakDurationSeconds of the break remain, the break is triggered for its remaining duration. The adbreakbegin event carries a tuneIn: { elapsedSec, remainingSec } payload, and the break-cut timer ends the break at the real break boundary (not start + 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 minBreakDurationSeconds remain, the break is not triggered (no partial sliver of an ad is shown, and it is not monetised).
Option Default Description
enabled true Whether in-progress breaks are joined. false restores the legacy behaviour of skipping any break first observed past its start.
minBreakDurationSeconds 5 Minimum remaining duration for an in-progress break to be presented. Below this, the break is skipped.
Tune-in only applies to a break the SDK first observes after its start time (e.g. a fresh join or a seek into a break). A break the SDK has already triggered on time is unaffected and always plays its full duration.
const sdk = new 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:

  1. creates the IMA DAI stream (for a stream_id),
  2. builds the stitcher master URL and loads it into the content player,
  3. forwards in-stream timedmetadata cues to IMA for ad tracking, and
  4. re-emits the usual ad-event stream (adbreakbegin / adbegin / quartiles / adend / adbreakend).
const sdk = new 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:

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

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

SessionConfig startSession()

Property Type Required Description
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.

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

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';
});
Web (@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).

All three SDKs expose the same 12 event types (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 statusAdBreakStatus 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'.

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

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

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

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

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

Web

TypeScript types

// Base — all events extend this
interface 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 of video.play() / video.pause() — the SDK will no-op these during content-locking breaks, preventing the user from accidentally resuming content mid-break.
  • Use sdk.seek(time) instead of setting video.currentTime directly — honours snapback enforcement.
  • Drive your countdown from adbreakstatus — the SDK emits this event whenever the break state or countdown changes. Use sdk.getAdBreakStatus() at any time for the current status, or subscribe to adbreakstatus for live updates. The status object includes phase ('idle' | 'upcoming' | 'active' | 'complete'), secondsUntilBreak, breakRemainingSec, adsRemaining, adIndex, totalAds, and ticking.
  • breakWarnings config controls pre-break warnings. Set breakWarnings: { seconds: [10, 5] } to receive adbreakstatus with phase: 'upcoming' at 10s and 5s before the break.
  • Dismiss the countdown on adbreakendadbreakend fires after all assets in the break have played (or been skipped/errored).

Minimal example

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

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

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

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

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

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

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

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

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

Android (Kotlin)

Subscribe with addEventListener(event: 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).

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

Web

Subscribe to diagnostics

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

// later
sdk.offDiagnostic(handler);

A DiagnosticEvent has this shape:

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

Export a shareable report

exportDiagnostics() returns a self-contained, redacted report — the recent diagnostic + event timeline plus a redacted config summary, the SDK version, and the user agent. It contains no secrets: 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.
Emitted SDK events are also captured into the diagnostic timeline under the DA-EVENT code (category event), so a single exported report shows diagnostics and the playback timeline together.

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`));
});
A transition that never completes (e.g. a break cut short before its first ad rendered, or a format that never pauses content and so never emits a content playing) is dropped rather than reported with a misleading duration — so an absent measurement is itself a signal.

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.

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

Web

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 — a FrameLayout matching the content bounds, passed to OverlayAdRenderer(context, overlayContainer, contentAdapter).
  • Route playback through the SDK — call sdk.play() / sdk.pause() / sdk.seek(seconds) instead of touching the ExoPlayer directly, so content-lock and snapback are honoured.
  • Swap controls for a break indicator on ADBREAKBEGIN / ADBREAKEND.
  • Drive a countdown from the ADTIMEUPDATE event (currentTime / duration) or from break_.duration.
// Hide your controls + show a break indicator during the break
sdk.addEventListener(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()
}
Mute/volume are synced between the content and ad players by the SDK; just update your own icon. Never hand your content ExoPlayer to the renderer — the SDK owns a separate ad player.

iOS / tvOS (Swift)

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

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

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

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

Ad Formats

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

Format Content paused? Companion? Description
single ✅ Yes Full-screen ad overlay. Content is hidden and paused.
double ❌ No ✅ Image (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.
The above are client-side (SGAI) layouts. The server-side SSAI Stitcher always stitches a fullscreen single ad, and by default also stitches double and lshape_ad as fullscreen single (their companion is dropped) — set stitchCompositedAsSingle: false to stitch only genuine single breaks. lshape_content and overlay are never stitched.

Manifest variant shapes

single / lshape_content / overlay — simple assets array

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

double / lshape_ad — assets with companion (flat)

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

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

overlay — position + size (fractional 0.0–1.0)

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

Image (non-linear) overlay assets

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

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

Layout DOM structure

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

Format playerContainer at break start .dolby-ad-container .dolby-companion
single base (full stage) top:0; left:0; right:0; bottom:0
double top:26.04%; left:1.04%; right:51.04%; bottom:26.04% (left box) top:26.04%; left:51.04%; right:1.04%; bottom:26.04% (right box) 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 start field is still required for schema compatibility but is ignored when position is 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 (default https://ads-sdk.xnappet.live) and org ID are pre-filled under Advanced, and pasting a full channel URL auto-fills all three.

Try it: the demo has a dedicated Pre-roll page (sidebar → Pre-roll) 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 interceptManifestResponse hook: the callback receives the parsed, validated BreakManifest (on the initial fetch and every poll) and prepends a position: "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 vast pre-roll using Google's public IMA sample tag. Create it as a channel, then play that channel on the Player page to watch a client-side VAST ad. See the VAST (CSAI) page for the full walkthrough.

Creative hosting requirements (CORS). Every asset uristatic 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.

The full manifest specification — including break variants, asset targeting, forward compatibility rules, and JSON schema — is documented in 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.

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

Request interception (interceptManifestRequest)

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

Return one of:

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

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

import { 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) }),
});
SGAI only. The same API exists on Android ((suspend (ManifestRequest, ManifestRequestContext) -> ManifestRequestResult?)?) and iOS (ManifestRequestInterceptor? returning .request/.mock/nil); both forward it to the injected ManifestSource, which applies it before the network fetch.

Response interception (interceptManifestResponse)

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

Key guarantees:

  • Typed, never raw. The callback receives the parsed BreakManifest (normalized variants arrays, parsed numbers/enums) — never the raw JSON. An invalid manifest fails validation before the hook runs, so it is never called with garbage.
  • Its return value is used as-is. Return the (possibly new) BreakManifest to schedule; it is used directly, without re-validation. Returning the input unchanged is a no-op.
  • Runs on every poll. Live channels re-poll; the hook is applied each time, so keep it pure/idempotent (e.g. guard against injecting the same break twice).
  • Failure is non-fatal. If the hook throws (or its promise rejects), the SDK emits the DA-MANIFEST-INTERCEPT-FAILED diagnostic and falls back to the unmodified parsed manifest — the fetch/poll does not fail.
  • May be async. Return a Promise<BreakManifest> to do async work (the poll awaits it).
import { 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,
      ],
    };
  },
});
SGAI only. The demo's Pre-roll feature on the Player page is built on this hook. The same API exists on Android ((suspend (BreakManifest, ManifestInterceptContext) -> BreakManifest)?) and iOS (ManifestResponseInterceptor?); both forward it to the injected ManifestSource, which applies it after parsing. See the per-platform config tables under SDK Configuration.

Google Ad Manager

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

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

Prerequisites

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

Configuration

const sdk = new 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:

  1. Uses the IMA StreamManager (initialized at startSession()) to build a pod manifest URL from the stream ID and pod ID.
  2. Loads that URL into the ad player (preloaded 5 seconds before break start).
  3. Pauses content and plays the ad overlay at break time.
  4. Forwards IMA metadata events for ad tracking (quartile beacons, etc.).
This page describes SGAI (client-side) pod serving, where the player builds the pod URL and composites the ad. The same GAM pod serving can also run server-side via the SSAI Stitcher: the client still creates the DAI stream and tracks, but the server requests the pod and bakes it into the HLS stream (reusing the same pod-URL builder).

AI Assistance

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

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

What you get

Artifact What it does Where it lives
Onboarding agent Tutor that explains the model step by step, shows where to start, then offers to bootstrap a demo. @dolby-ads/sdkai/agents/dolby-onboarding/AGENT.md
Quickstart skill Collects a few inputs and bootstraps a runnable single-file demo — with or without MCP. @dolby-ads/sdkai/skills/dolby-quickstart-demo/SKILL.md
Adapter skill Bounded instructions to scaffold + validate a PlayerAdapter. @dolby-ads/sdkai/skills/dolby-adapter-integration/SKILL.md
Troubleshooter agent Diagnostic-reasoning instructions for reading a report. @dolby-ads/sdkai/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.

The same copy logic is exported programmatically as 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

  1. Reproduce the issue with the SDK running.
  2. Capture a redacted report: const report = sdk.exportDiagnostics(); (no ad-tag-parameter values or secrets are ever included — see Diagnostics).
  3. Hand the report to your AI assistant ("analyze these Dolby Ads diagnostics") — with the MCP server registered it calls analyze_diagnostics; otherwise the pasted JSON plus the bundled knowledge base is enough.
  4. You get a root cause and concrete fix steps.

You can try the exact same analysis, fully client-side, in the AI Troubleshooting card on the demo playerExport 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, and overlay formats, their layout rules, and when to use each.
  • "Which player can I use?" — it helps you choose between HLS.js, Shaka, THEOplayer, native HLS (Safari/iOS), or a custom adapter, and can scaffold the integration for you (see Step 5).
  • "What about advanced behaviours?" — it can explain and help you implement tune-in (waiting for a live edge before playing ads), chaining (back-to-back breaks), and overlap suppression (preventing duplicate ad breaks at the same timecode).

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

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

SSAI Stitcher (server-side)

The 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/stitcher server bakes the ad into the HLS stream the player receives. The player just plays one stitched stream.

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

When to use SSAI

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

How it works (hybrid DAI)

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

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

Supported formats

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

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

URL contract (summary)

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

Boundary snap policy

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

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

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

Guarantees

  • Fail-open: if the break manifest, pod, or parameters are unusable, the player receives the unmodified content — playback never breaks.
  • Timebase: pts (cumulative EXTINF) and wallclock (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 PlayerAdapter must expose videoElement (IMA DAI binds to a real <video>). HLS.js, Shaka, and the native-video adapter qualify.
  • mode: 'ssai' requires stitcherBaseUrl and gam.networkCode; startSession requires customAssetKey.
  • customAssetKey/adTagParameters come from startSession; org-level gam.adTagParameters are merged with the per-session ones.
  • Failures surface as DA-SSAI-SESSION-FAILED (startup) and DA-SSAI-IMA-ERROR (in-stream); ad lifecycle is emitted as the usual adbreakbegin / adbegin / quartiles / adend / adbreakend events.

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

Trying it in this demo

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

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

Break Manifest Server

The @dolby-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:

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

Programmatic Use

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

const app = createBreakManifestApp({
  orgId: '0bda787b-b10a-4e9e-a5f2-c0952d1b8a88',
  port: 4100,
});

app.listen(4100);

Installing the SDK (beta artefacts)

Beta distribution. The SDK is not yet published to public package registries. Instead, every release is built and hosted on the artefact host (https://ads-sdk.xagget.prudentgiraffe.com, one immutable <platform>/<branch>/<version>/ prefix per build) so you can install it with the standard package managers straight from an HTTP endpoint. Versions track the SDK release shown in the sidebar.

Web (npm)

Each web package — the SDK core, the entry SDK, the player adapters, and the AI helpers — is published as an npm pack tarball under /web/develop/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).

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

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

import Hls from 'hls.js';
import { 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 is SGAI-only — never SSAI. VAST assets are only honoured in client-side (SGAI) sessions. In a server-side (SSAI) session the server stitches the ad break and the client never reads the break manifest, so a vast asset there is rejected with a DA-VAST-SSAI-UNSUPPORTED diagnostic.

Supported formats (linear-only)

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

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

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

Asset shape

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

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

See the full schema in the 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:

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

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

Error handling

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

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

Try it in the demo

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

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

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

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

Pause ads

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

It is signalled in the break manifest with position: "pause" and the pause break format (the legacy pause_image value is still accepted and resolves identically).

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

Creative sources

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

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

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

Resume affordance

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

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:

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

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

The status bar contract

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

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

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

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

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

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

Branding tokens

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

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

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

Light vs dark surfaces

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

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

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

Choosing the content player

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

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

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

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

Client-side ad-break manifest (no backend)

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

const sdk = new 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 Server node. The Dashboard sits alone on the top row; Origin, Ads API and Ad Server share the middle row; Player and Ads SDK share the bottom row — so every cross-lane arrow (break detection, Notify, Ad Insertion) plus the vertical Content arrow is one straight line. The Ads SDK's Ad call arrow still enters the green block from the bottom-centre so the two ad-provider touchpoints read as clearly different — no stitcher/SSAI/EABN terminology.
  • No flow list under the diagram — the labelled arrows speak for themselves; the three numbered touchpoints render as a vertical ordered list (cd-explain-steps), not one inline sentence.
  • No standalone "Operations" node. The Dashboard configures and schedules breaks directly on the Ads API (Configure & schedule); the break-detection link is drawn dashed, labelled Break detection, from the Ads API to the Origin (PLAYG-303 — the service reads the stream's own SCTE markers — optional/dashed; customers can create breaks via the Dashboard/Ads API instead).
  • Three numbered touchpoint badges sit directly on the nodes a customer touches — 1 Dashboard (configure), 2 Origin (connect your stream), 3 Player (add the SDK) — so there is no separate dimmed "integration" diagram to keep in sync with the architecture one.

The diagram renders inside a fixed light .cd-diagram-panel, independent of the page's own brand theme, so the semantic colours read correctly on both a light brand (Bally's) and a dark brand (GloboPlay).

The Ads SDK section explains the adapter approach (a thin translation layer; the snippet names only HLS.js as the example player) and the AI section explains the AI enablement in full: local knowledge installed by npx 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 (see THEO_LIBRARY_LOCATION in packages/demo/src/player-factory.js) — if it points at a different version, transmux fails silently. Keep the configured library location and the installed theoplayer package version in lockstep.
  • Client-side manifest. Scte35Client (packages/demo/src/customer-demo/scte35-client.ts) fetches the stream's playlist (following one master → variant hop), parses its #EXT-X-CUE-OUT:DURATION= markers with the shared @dolby-ads/scte35-bridge parser, and returns a wallclock break manifest. Bally's carries no SCTE-35 binary payload and no EXT-X-DATERANGE, so the break start comes from the nearest #EXT-X-PROGRAM-DATE-TIME and the duration from the DURATION value. The manifest is handed to the SDK via interceptManifestRequest returning { body }, so there is no ad-manifest backend.
  • Ad format + 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 pmnd and pmxd query parameters (the min/max ad-pod duration in milliseconds), set to the break's duration by vastTagForBreakDuration() in packages/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 from interceptManifestRequest. 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 uses position: 'pre', pause uses position: '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's programDateTime, using the shared clampSeekTarget helper) so you can watch the break fire — the same DVR seek-back demo as the built-in DVR page.
  • Player UI + muted autoplay. Start mutes the THEOplayer and autoplays the stream (muted autoplay is never blocked by the browser). A basic control bar — play/pause, mute/unmute, a DVR seek slider over the seekable window, and a time readout — sits under the player. Muted autoplay also matters for correctness: until playback progresses THEOplayer does not expose EXT-X-PROGRAM-DATE-TIME, so the SDK logs the non-fatal DA-PDT-MISSING (wallclock breaks fall back to the system clock); once the stream is playing, PDT is available and the warning stops.
  • THEOplayer library must be same-origin for TS streams. The Bally stream is HLS/TS; THEOplayer transmuxes it via a worker + helper iframe.html loaded from libraryLocation. A cross-origin libraryLocation (e.g. a public CDN) makes that worker/iframe messaging stall silently — segments download but nothing is appended to the MediaSource, so you get a permanent black screen with no error. The demo therefore self-hosts the library on our own origin (THEO_LIBRARY_LOCATION = '/theoplayer/'; vite.config.js stages the installed build into public/theoplayer/, gitignored). fMP4 streams don't hit this (no transmux), which is why the built-in DVR demo never exposed it.
  • 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. assembleBallysManifest only adds them when podDuration: 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 exposes start/stop/seek/setConfig/getState/getLog. Note the broker needs the office VPN while the stream needs a US VPN, so live on-device runs of this stream aren't currently possible; local Playwright (US VPN, no broker) is the practical check.
  • Prerequisites. The stream is US-only, so enable a US VPN before starting. The in-browser playlist fetch also needs the stream to allow cross-origin reads; if it is blocked by CORS, add a dev-only Vite proxy for local runs.

Worked example: GloboPlay (/globoplay.html)

The GloboPlay demo (packages/demo/src/globoplay.js) shows the same SDK against a low-latency HLS stream with a live ad-manifest backend and a viewer-selected player — a useful contrast to Bally's THEOplayer + no-backend design:

  • Selectable player. The page mounts the shared player picker (HLS.js or Shaka, with logos) and builds the content player for the choice via player-factory. Both play the low-latency HLS stream https://ll-hls.softvelum.com/sldp/bbloop/playlist.m3u8.

  • Mid-rolls from a real endpoint (server-side breaks). Unlike Bally's, the break manifest is not built in the browser. The SDK is configured with manifestBaseUrl pointing at the staging ad-manifest service and started with the channel id, so it fetches and polls the channel over the network. Breaks are punched server-side into that channel.

  • Client-injected pre-roll + pause. On top of those server mid-rolls, the page layers an optional pre-roll and pause ad using the reusable injectPreRoll / injectPause helpers (see "Injecting pre-roll / pause on a live backend" above) via interceptManifestResponse. Both have a delay (seconds) input, so you can demo a pre-roll that fires after N seconds of playback or a pause ad that appears only after the content has been paused for N seconds. The pause ad uses a GloboPlay still:

    GloboPlay pause ad

  • Dark branding. GloboPlay is a dark brand: a black surface with an orange→red gradient accent and the GloboPlay logo, using the shell's dark-surface theming tokens (see "Light vs dark surfaces" above).

  • Embedded Xagget agent (dormant). Like Bally's, the page embeds the dormant Xagget device agent so it can be driven on a lab browser when opened with the Xagget bootstrap querystring; it stays inert in normal use.

  • Prerequisites. The content stream and the staging ad-manifest endpoint must be reachable from the browser (CORS); if a fetch is blocked, add a dev-only Vite proxy for local runs.

Worked example: Bell Media (/bellmedia.html)

The Bell Media demo (packages/demo/src/bellmedia.js) follows the same server-side-mid-rolls + client-injected-pre-roll/pause pattern as GloboPlay, with Bell Media's own stream, ad-manifest channel, and artwork:

  • Selectable player. Same player picker as GloboPlay (HLS.js or Shaka), playing Bell Media's stream (https://discovery.theo.live/v2/distributions/demo/hls/main.m3u8).
  • Mid-rolls from a real endpoint (server-side breaks). The SDK is configured with manifestBaseUrl pointing at the same staging ad-manifest service GloboPlay uses, started with Bell Media's own channel id, so mid-roll breaks are punched server-side.
  • Client-injected pre-roll + pause, with format-specific companions. The page layers an optional pre-roll and pause ad on top via injectPreRoll / injectPause. The linear formats (Single/Double/L-Shape) fill from the same VAST pod tag as GloboPlay/Bally's (pmnd/pmxd sized to the 15s pre-roll); unlike GloboPlay's single backdrop image, Bell Media's Double and L-Shape formats each additionally carry their own companion artwork — bell-doublebox.png for Double, bell-lbar.png for L-Shape — matching the format they are shown on. Overlay is a non-linear Bell Media logo image (no VAST), and the pause ad uses a Bell Media still (or an MP4 for the video option). Companion images apply to the pre-roll only; server mid-rolls play as delivered.
  • Light branding. Bell Media is a light brand (white surface, Bell blue #0065a4 accent, sampled from the Bell Media logo), using the same light defaults as Bally's.
  • Embedded Xagget agent (dormant). Same dormant Xagget device agent pattern as Bally's/GloboPlay.

Adding a new customer demo

  1. Add a CustomerDemo entry to CUSTOMER_DEMOS in packages/demo/src/customer-demo/registry.ts (id, name, description, href, brand). The portal tile appears automatically, with the name shown in uppercase.
  2. Create <customer>.html and packages/demo/src/<customer>.js; mount the shell and wire the SDK for the customer's stream.
  3. Mount the shared explainer (mountExplainer from ./customer-demo/explainer, see above) below the demo markup, with the customer's customerName and wiredBullets.
  4. Register the page as a Vite input in packages/demo/vite.config.js.
  5. 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). secondsUntilBreak is the live countdown to the break.
  • active — a break is playing. Show the badge; render the countdown from breakRemainingSec and the counter from adIndex / totalAds.
  • complete — the break finished. Dismiss the break UI (mirrors adbreakend).

How to use

Subscribe for live updates, and/or read the current status on demand:

import type { AdBreakStatusEvent } from '@dolby-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();
Drive your countdown from breakRemainingSec — do not run your own timer. The SDK gates the countdown on real playback progress (ticking), so it stays correct through buffering, tune-in (join-in-progress), and held pre-rolls, and it advances monotonically without flicker.

How it can be configured

adbreakstatus requires no configuration for the active / complete / idle phases — those are always emitted. The one configurable piece is the pre-break warning (phase: 'upcoming'), controlled by the breakWarnings option passed at construction:

const sdk = new 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 ([]), no upcoming warnings fire; you still get the full active countdown.

Using it for pre-roll

Pre-rolls are the most common place to show a countdown, and they need no special handling — subscribe once and render on phase === 'active':

  • No upcoming warning — a pre-roll has no pre-break content, so the first status you see for it is phase: 'active'.

  • ticking gates the first frame — for a held/first-frame-gated pre-roll, the status is emitted with ticking: false until the ad's first frame renders. Render a static "Ad break" badge while ticking is false, and switch to the live breakRemainingSec countdown once it flips to true. This avoids showing a countdown against a frame that has not started.

  • Monotonic countdown — once ticking, breakRemainingSec counts down smoothly to 0 (including CSAI/VAST pre-rolls, which advance from IMA ad progress). It never rewinds.

  • Dismiss on complete / adbreakend — hide the badge when phase becomes complete (or on the adbreakend event).

  • A short, healthy pre-roll can complete extremely fast. startSession() starts the break scheduler's 250ms tick and returns; a delay: 0 pre-roll can fire on that very first tick, and if the creative loads quickly the whole lifecycle (adbreakbeginadbeginadendadbreakend) can complete in well under a second — potentially before your app resumes from await sdk.startSession(...) and gets around to attaching listeners or polling sdk.getAdBreakStatus(). Register adbreakstatus/ad-event listeners before calling startSession, not after, so you never race a fast pre-roll:

    sdk.addEventListener('adbreakstatus', onStatus);
    sdk.addEventListener('adbreakbegin', onBreakBegin);
    // … then start the session
    await sdk.startSession({ 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 read programDateTime === null on a stream that carries PDT perfectly well. The scheduler treated that first null as proof the stream had none: it emitted the one-shot DA-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 "adbreakbegin 1.1s after startSession, 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 after pdtGraceSeconds (new, default 5) 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). pdtGraceSeconds is 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 stamps develop/0.43.1-beta.56 from the deploying pipeline (DOLBY_ADS_CHANNEL/DOLBY_ADS_VERSION), so the develop preview site's install docs point at the exact develop/<beta> artefacts that same pipeline published, while release builds keep main/<version>.

Added

  • The development workflow now targets develop, not main. scripts/bb.sh pr-create defaulted its destination to main and scripts/new-worktree.sh branched off origin/main, with AGENTS.md, CONTRIBUTING.md, RELEASING.md, docs/worktrees.md, docs/develop-workflow-details.md, .windsurf/workflows/develop.md and the Devin playbook all describing main as the PR target — while the actual branch model is develop integrates, main releases (a version cut advances main and is back-merged into develop). Following the documented flow therefore put unreleased work on the release branch and left develop without it, which is exactly what happened to PLAYG-360. The scripts now default to develop/origin/develop and every doc states the model explicitly.

  • pdtGraceSeconds configuration (web, Android, iOS). Backstop for how long to wait for the stream's EXT-X-PROGRAM-DATE-TIME on a wallclock-timebase session before concluding the stream carries none, used only while the content player has not started playing. Default 5; set 0 for the previous immediate clock fallback, or raise it when an adapter is known to surface PDT unusually late while already playing. No effect on pts manifests. The DA-PDT-MISSING diagnostic now carries context.graceSeconds and context.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 diagnostics array ({ 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 accept programDateTime: null to model a stream that has not exposed a PDT yet, and pdtGraceSeconds; a step combining currentTime > 0 with programDateTime: null models 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 vendor asset in the break manifest, so the SDK's ad sequencer — which plans one adbegin/adend pair per manifest asset — could only ever announce ad 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 one adbegin and one adend, 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 portable GamPodAdTracker: one adbegin/adend pair per real ad, with truthful adIndex/totalAds, a per-ad asset derived from the manifest pod asset as <podAssetId>-ad-<n>, and IMA's adId/creativeId on both events. adbreakbegin/adbreakend still 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 first adbegin waits 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, iOS avplayer emits 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. adtimeupdate deliberately stays pod-level: a pod is one stitched stream, so its currentTime/duration describe the whole pod rather than the creative on screen, and its asset remains the manifest pod asset.
  • Web: quartile events could not be attributed to an ad inside a GAM pod. adfirstquartile/admidpoint/adthirdquartile carried only the pod asset, so six cycles under one reported ad were indistinguishable from each other — three admidpoint events for what the API called one ad have no valid interpretation for an analytics or tracking consumer. All three quartile events now carry the optional adIndex/totalAds of the ad they belong to, and the pod ad's own derived asset. 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/url fields (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 at ads-sdk.xagget.prudentgiraffe.com; override with the ARTIFACTS_PUBLIC_BASE project 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's AVPlayerLayer by 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 .playback on the way, since AVPictureInPictureController will 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 single SEMANTICS: 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_content is 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 by DA-PIP-FORMAT-OVERRIDE with midBreak: 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 — and insertion is 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's load() 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 of adbreakbegin.

  • 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 (clearing source); engines that swap in place need not implement it.

  • The SDK lost track of the content element when the player swapped it. setup*Tracking() and teardown*Tracking() each resolved contentPlayer.videoElement fresh, 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 saw enterpictureinpicture or webkitpresentationmodechanged again: 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 as DA-CONTENT-ELEMENT-SWAPPED.

  • releaseMediaElement is a declared adapter capability. @dolby-ads/adapter-test-kit's conformance suite now has a releaseMediaElement capability flag and a check that releasing does not tear the adapter down — the SDK hands the element back by calling load() 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 a double break played correctly as a single ad in the window — the renderer had already overridden its own layout — while adbreakbegin announced format=double to 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-OVERRIDE records 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 own isPictureInPicturePossible KVO notification under MainActor.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, and isPictureInPicturePossible still 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 raises DA-PIP-TRANSFER-FAILED rather 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, webkitPresentationMode read inline throughout while document.pictureInPictureElement correctly 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 single layout, 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 as single for 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 declared single break 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-OVERRIDE reports each override with the declared and effective formats.

  • DolbyAds.setPictureInPicture(), isPictureInPicture() and getPresentationState(). 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 plus setPictureInPicture() 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 optional PlayerAdapter.sourceUrl reporting the manifest the engine is playing (hls.url, Shaka's getAssetUri(), the element's own src for 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 errored after the normal deviceRetries cycle 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 to maxDeferredRetries rounds, default 10) and retries a fresh allocation cycle as long as a sibling platform is still active — stopping to report a hard errored immediately 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's timeupdate, but on the shared-element path there is no ad player: the ad plays through the content element, where only an ended handler was ever attached. An ad could play perfectly while the application was told nothing and adbreakstatus.breakRemainingSec never 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 second adbreakbegin 265ms 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 playing event, 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 aderror instead of running the break out in silence. The renderer waited for STATE_READY or 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 no adbegin, no aderror and 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 — ID3 GEOB frames left undecoded, and cues observed too late (PLAYG-349). On a live HLS stream whose MPEG-TS segments carry Anvato ID3 GEOB markers, every stream-signaled break stayed DA-ANVATO-CUE-PENDING forever on Shaka, while hls.js and native Safari played the same breaks fine. Two adapter-side defects, both confirmed against shaka-player@4.16.36: (1) GEOB is never pre-parsed by Shaka. ShakaAdapter.toCue() assumed Shaka hands over a decoded frame (description: 'Anvatos', MIME application/json, object data) the way hls.js, THEOplayer and WebKit do, but shaka.util.Id3Utils only decodes APIC/TXXX/WXXX/PRIV/T*/W* — a GEOB frame 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, so isAnvatoCue() rejected every marker. (2) Cues arrived at the playhead, not ahead of it. The adapter listened only to Shaka's metadata event, which is dispatched from a RegionObserver '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. ShakaAdapter now decodes the raw GEOB body itself (via the shared parser) and additionally subscribes to Shaka's parse-time metadataadded event, de-duplicating per marker so IMA never receives a doubled tracking cue — the same remedy as the Safari track.cues sweep in 0.40.2. Players that DO pre-parse GEOB are unaffected. Regression-tested by running real Anvato ID3 tags through Shaka's own Id3Utils in ShakaAdapter.tsId3.test.ts. Known limitation: Shaka exposes no parse-time event for DASH emsg, so Anvato-over-DASH on Shaka still resolves its cue only at the playhead (PLAYG-351).
  • @dolby-ads/adapter-hlsjs no longer owns the ID3 GEOB parser. It moved to the new player-agnostic @dolby-ads/core/id3 subpath so the Shaka adapter can share it (an adapter must never depend on another adapter), gaining parseId3GeobBody() for players that surface a bare frame body. @dolby-ads/adapter-hlsjs re-exports parseId3GeobFrames, decodeUtf8 and Id3GeobFrame unchanged, 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 — useAnvatoID3 breaks stayed pending forever. AVPlayerAdapter attached its AVPlayerItemMetadataOutput — the only source of .timedmetadata events — solely inside AVPlayerAdapter.load(_:). But the documented integration pattern is that the integrator owns content loading and sets the item via player.replaceCurrentItem(...) directly (the SDK's own DolbyAdsDemo does 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 stayed DA-ANVATO-CUE-PENDING — the break window passed with no adbreakbegin. 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 a player.currentItem KVO observation (.initial covers an item set before the adapter existed; .new covers both the integrator's replaceCurrentItem and the SDK's load(_:)), idempotent per item so load(_:) and the observer never double-attach, torn down in destroy(). This brings AVPlayer to parity with Kotlin's ExoPlayerAdapter (which listens on the player) and the web adapters — cues now flow no matter who loads the content. The demo needs no change; its existing replaceCurrentItem flow is what the fix makes work. Regression-tested in AVPlayerAdapterTests (integrator-loaded item, item present before init, no double-attach with load(_:), item replacement); live coverage is the ads-sdk-testing NFL-CH-AVPLAYER suite. Sibling of the Safari NativeVideoAdapter cue fix in 0.40.2 — no portable-brain change, so no conformance fixture applies.

Added

  • DA-BREAK-TRANSITION sub-phase attribution: where a slow transition actually spends its time (PLAYG-344). The diagnostic previously reported one opaque durationMs per transition — enough to prove the QN86D's 2574ms into-break cost is unacceptable, but not to target it. BreakTransitionTimer gains phase(name), recording ordered checkpoints ({ name, atMs, sinceLastMs }) onto the pending measurement, surfaced as context.phases. AdPlayerController reports uri-resolved, load-start/load-resolved (or load-skipped-preloaded when the break was already preloaded), and play-called, which splits the window into uri resolve / attach+manifest / first-frame decode without any player-specific code in core — the reporter is injected via setTransitionPhaseReporter(), mirroring the existing setChainResolver() seam. Collected only when the SDK is constructed with debug: true, and the phases key 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 ID3 DataCues skipped by cuechange. NativeVideoAdapter surfaced timed metadata only via cuechange/activeCues, but WebKit does not reliably fire cuechange for (near-)zero-duration cues — exactly the shape of Anvato break/beacon markers (observed live on NFL Network: endTime − startTime ≈ 10 µs, present in track.cues but never in activeCues). Result: on Safari native HLS, DA-ANVATO-CUE-PENDING breaks stayed pending forever and stream-signaled ad breaks were missed. The adapter now ALSO sweeps track.cues on timeupdate (per-cue deduplicated against the cuechange path, 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 in NativeVideoAdapter.test.ts; live coverage is the existing id3-pts native/mac-safari cell and the ads-sdk-testing NFL-*-NATIVE suites.
  • E2E test-app: GAM DAI pods never played on THEOplayer — First frame timeout on every GAM preroll cell (PLAYG-341). The test-app pointed THEOplayer's libraryLocation at the jsdelivr CDN. THEO's TS transmuxer loads a worker + helper iframe.html from that location, and a cross-origin location stalls TS/HLS playback silently: segments download but are never transmuxed/appended, buffered stays empty, no error fires — so the first-frame gate (held-immediate fullscreen pre-rolls) timed out on all three swept platforms, and neg-gam-failure mis-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.js documents the same constraint): packages/test-app/vite.config.ts now stages the installed THEOplayer build into public/theoplayer (gitignored, always version-matched per PLAYG-220) and THEO_LIBRARY_LOCATION resolves page-relative (./theoplayer/, compatible with the Xagget store's base path). Regression-tested in adapters.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, looping adbreakend → ~5s content → adbreakbegin forever (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 new pts-anvato-no-rearm fixture; 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's timebase: "wallclock", but liveness is a property of the CONTENT: a pts channel can sit on a live stream too (the NFL Channel/Network useAnvatoID3 case, 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 to 0, playing fired 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 optional PlayerAdapter.isLive capability hint (Kotlin), which ExoPlayerAdapter maps from Media3's isCurrentMediaItemDynamic (ExoPlayer's duration is 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 in OverlayAdRendererLiveLatencyTest (pts+live restores the pre-break distance from live; VOD DAR still never seeks) and ExoPlayerAdapterTest (no timeline → isLive unknown).

[0.40.0] - 2026-07-30

Fixed

  • adPreload: 'auto' deadlocked playback on modern Smart TVs (PLAYG-319). The auto heuristic (isSingleDecoderUserAgent, packages/core/src/services/detectPreloadMode.ts) gated single-decoder on TV firmware version (Tizen ≤3 / webOS ≤3), so a current Samsung/LG panel resolved to parallel and 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, so auto now resolves any Tizen or webOS UA to single-decoder. Over-matching is cheap and bounded: single-decoder still 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 explicit adPreload: 'parallel' is still honoured on TVs (documented foot-gun). Verified on the QN86D: autosingle-decoder completes the break and resumes content, explicit single-decoder behaves identically, forced parallel still deadlocks. Mirrored in the Kotlin (android/dolbyads-core/.../ModeResolution.kt) and Swift (ios/DolbyAdsCore/.../ModeResolution.swift) cores and locked by the modes-tizen8 conformance fixture.

Added

  • DA-BREAK-TRANSITION diagnostic: measured cost of every playback transition around a break (PLAYG-319). New BreakTransitionTimer (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, with context.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 resolved context.adPreload/adInsertion so 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.md gains a "Tizen TVs (Samsung)" section: the Xagget kit's skill 05 covers only web/android/ios, and the one rule that breaks every first attempt was written down nowhere — appUrl must be the Xagget Tizen skeleton .wgt (permanent builtin at artifacts/builtin/tizen/xagget-skeleton-samsung.wgt), not the web bundle, which belongs in target.launchParams.bundleUrl; pointing appUrl at index.html makes the installer run tizen install on the HTML and fail with Failed to install Tizen application.. The section adds a copy-pastable request_device payload verified on the office QN86D, the fixed packageId (ABCDEF1234.XaggetSkeleton), the targetId vs hardwareId distinction, mode: redirect vs inject, how to probe the index-less artifact store (HEAD405), the office-win-ctv / office-mac-tvlabs registry 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-level DolbyAdsConfig.useAnvatoID3 flag (default false, SGAI only): 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, payload type=cue&pts=<seconds> — and resolves each timebase: "pts" break's start by matching break.start against an observed cue's pts (±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-shot DA-ANVATO-CUE-PENDING diagnostic; DA-ANVATO-CUE-MATCHED on 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 new pts-anvato-cue-match / pts-anvato-no-cue / pts-anvato-late-cue fixtures. TimedMetadataCue gained optional description/mimeType normalization fields, surfaced by the hls.js (new ID3 GEOB decoder), Shaka, THEOplayer, Media3, and AVPlayer adapters. Demo: "Anvato ID3 break signaling" toggle on the player page. E2E: new id3-pts feature (scenario id3-pts-static-single-vod-dar + injectTimedMetadata device command).

Fixed

  • E2E test-app: THEOplayer First frame timeout on held-immediate fullscreen pre-rolls (PLAYG-330). The test-app created its THEOplayer ChromelessPlayer instances 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-less play() — the ad player never fires playing, so the SDK's first-frame gate (active only for held-immediate single/lshape_ad pre-rolls, the exact failing subset of the PLAYG-239 sweep) timed out with DA-AD-PLAYBACK-ERROR: First frame timeout. mutedAutoplay: 'all' does not cover manual play() calls. Both THEO instances are now created with muted = true in packages/test-app/src/adapters.ts (autoplay-policy parity with appendAdVideo), regression-tested in adapters.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() + the adclick event — skip/click parity with iOS and web. The Android DolbyAds facade had no viewer skip/click surface at all (no methods, no adclick event), which is why the Android native test agent left skipAd/clickAd unregistered and the e2e catalog-controls G3/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.skipOffset declared AND at least that many playback-gated seconds elapsed — the same currentBreakElapsedSec() the countdown uses) and cuts the break through the renderer's normal break-cut path so the sequencer still emits the balanced adend + adbreakend; DolbyAds.clickAd() emits the new AdClickEvent carrying the asset's declared interaction.clickThrough URL (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 new AdRenderer.setAdClickHandler seam. Supporting model work: the Kotlin core Asset gained interaction.clickThrough (parsed by ManifestService, mirroring Swift's toInteraction), and the DA-AD-SKIPPED / DA-AD-SKIP-SUPPRESSED / DA-AD-CLICKED diagnostic codes are registered. Unit-tested in DolbyAdsSkipClickTest (the Kotlin mirror of DolbyAdsSkipClickTests.swift).

Fixed

  • Pre-roll breaks never fired on iOS. The iOS DolbyAds session layer never called BreakScheduler.markSessionStart(), so sessionStartMs stayed nil and checkPreRollBreak bailed on every tick — every position: "pre" break silently never played (web calls it at packages/core/src/DolbyAds.ts:513; the conformance CLIs call it themselves, which masked the gap). startSession() now anchors the session clock before updateManifest, mirroring the TS reference and the Android fix on features/android-sdk; regression-tested in PreRollSessionStartTests. Found by the on-device E2E pre-roll/pre-roll-delayed scenarios.

  • Pause-position breaks were admitted into timeline scheduling (all cores). No core's BreakScheduler skipped position: "pause" breaks, so their numeric start triggered them as linear breaks — benign-looking on web (extractAssets returns no assets, but a spurious adbreakbegin/adbreakend pair still fired), and actively harmful natively where toSeqBreak fed the pause variant's assets into real playout. updateManifest now never tracks pause breaks (they are PauseAdController-owned) in the TS reference and Swift core, conformance-locked by the new pause-break-not-scheduled fixture; Kotlin backfilled 2026-07-29 (same one-line guard in the Kotlin updateManifest; the pendingFixtures skip is removed from conformance/adapters/kotlin/core.json and --cores=ts,kotlin passes 106/106). Found by the on-device E2E pause-ad-vast crash.

  • iOS: client-side IMA construction crashed off the main thread, and requests failed with a nil adContainerViewController. ImaVastAdManager.playVast (CSAI) and GamStreamManager.initialize (DAI) built IMAAdsLoader/IMAAdDisplayContainer on the caller's cooperative-pool executor — IMA attaches an IMAWKWebView to 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 in DA-VAST-IMA-ERROR for triage. With these fixes the full-tier linear VAST scenarios play end-to-end on the simulator — note iOS IMA blocks cleartext http:// ad tags as mixed content, so full-tier runs need an https E2E_VAST_TAG (documented in e2e/ios/README.md).

  • iOS: AVPlayerAdapter.currentTime reported the stale pre-seek position while a seek was in flight, re-firing just-completed chained breaks. AVPlayer.seek(to:) is asynchronous and currentTime() keeps returning the old position until it lands — but the portable core is written against the web contract where assigning video.currentTime is 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 as currentTime until the seek completes, with a monotonic generation counter so a superseded seek's completion never unmasks a stale position (and load() clears any in-flight target). Regression-tested in AVPlayerAdapterSeekTests.

  • 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 — iOS UIImage/Android BitmapFactory cannot decode SVG (the manifest spec explicitly anticipates this), so every image-asset scenario raised aderror on iOS; the mock now serves PNG rasterizations (e2e/media/*.png, the .svg sources remain the editable originals). (2) The demo's hidden e2e-pause/e2e-resume XCUITest probes were squeezed to zero width by their 1×1 HStack and 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 native DolbyAds SDK layers; and a VAST pause-break crash in ImaVastAdManager.playVast constructing IMAAdsLoader off 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.swift and android/dolbyads-demo's ScenarioInstrumentedTest — 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 slimmed e2e/package.json (mock + its tests only; the Playwright/BrowserStack web parts stay removed — web E2E remains Xagget-only in packages/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), and docs/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 directed installerId (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-spaced scenario blocked by browser autoplay policy on shaka (PLAYG-328). defineSpacedBreaksScenario (packages/test-app/src/scenarios.ts) called play() unmuted with no preceding user gesture; desktop browsers block that, so both spaced-breaks-static-vod-dar and spaced-breaks-vast-vod-dar errored (HANDLER_FAILED) on all three PLAYG-238-swept shaka platforms (MAC/Chrome, MAC/Safari, Win/Edge). Same root cause as double-audio-focus (PLAYG-292); fixed the same way — setMuted({ muted: true }) before play(). 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.md is replaced by docs/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 deviceType asset targeting was unimplemented — the SDK always played uri[0] (PLAYG-317). AdPlayerController.resolveUri returned uri[0].value unconditionally for a static/vast asset's AssetUri[], ignoring each entry's targeting.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 portable resolveTargetedUri(uri, deviceType) (packages/core/src/services/resolveTargetedUri.ts): an entry whose targeting.deviceType matches wins; otherwise the manifest's untargeted (default) entry is used; otherwise uri[0] (so playback still proceeds). The device type itself is detected once per session from the UA via the new web-only detectDeviceType (packages/core/src/services/detectDeviceType.ts) — analogous UA-sniffing glue to the existing isSharedElementUserAgent/isSingleDecoderUserAgent heuristics, not portable brain behaviour. Conformance-locked on TS (asset-uri-device-targeting fixture); Kotlin/Swift don't yet model AssetUri[]/Targeting in their portable Asset type (deferred to "the player phase" per their own doc comments), so parity is tracked as a pending gap (conformance/PENDING-PARITY.md, pendingFixtures skip) until each native core's asset model adds it. AdPlayerController.deviceTargeting.test.ts's two it.failing PLAYG-313 repro cases now pass, plus a new fallback-to-default test.

  • Quartile events (adfirstquartile/admidpoint/adthirdquartile) never fired for static assets (PLAYG-318). AdPlayerController's ad-player timeupdate handler only forwarded onAdTimeUpdate; quartile callbacks were only ever raised from the GAM pod-serving path (GamStreamManager IMA callbacks) and the VAST/CSAI path (VastAdManager), never for a plain static media asset — any analytics/tracking relying on quartiles got zero signal for static creatives even though adtimeupdate ticked normally. The timeupdate handler now computes 25/50/75% progress itself for static assets only (each threshold fires once per asset, reset when the next asset starts) and raises the same GamEventCallbacks quartile 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 in AdPlayerController.staticQuartiles.test.ts (flipped from the it.failing PLAYG-314 repro, plus a new VAST-exclusion test).

  • double/lshape_ad/lshape_content transition-out briefly went black instead of smoothly revealing content (PLAYG-320). restoreLayout() reset playerContainer's inline style to the base style, which has no z-index, right as the break ends — for the 300ms fade-out, playerContainer (now z-index:auto) fell behind the still-visible, still-fading adContainer (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 keeps playerContainer at its break-time z-index:101 for the duration of the transition for these three formats (position/backdrop-repositioning formats only — single/overlay are 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 to AdPlayerController.test.ts.

Added

  • E2E: multi-break-spaced repro canary for external e2e hang findings (PLAYG-309/PLAYG-311). Two new fixed-name Xagget scenarios, spaced-breaks-static-vod-dar and spaced-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). New multi-break-spaced FeatureId/FeatureSpec in runner/matrix.ts, matching alias in runner/scenario-id.ts.
  • Core: regression tests reproducing two external e2e findings (PLAYG-309). AdPlayerController.deviceTargeting.test.ts (PLAYG-313) proves resolveUri always returns uri[0], ignoring each AssetUri's targeting.deviceType; AdPlayerController.staticQuartiles.test.ts (PLAYG-314) proves the static-asset timeupdate path never raises adfirstquartile/admidpoint/adthirdquartile (only the GAM/VAST IMA callback paths do). Both are kept as it.failing regression 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 resumeOffset semantics (PLAYG-309/PLAYG-316). Three "sharp edges" surfaced by external e2e testing are now documented: (1) packages/demo/docs/12-manifest.md gains a prominent note that every asset uri (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 answer HEAD, with the MEDIA_ELEMENT_ERROR: Format error symptom called out; (2) packages/demo/docs/21-adbreakstatus.md explains that a delay: 0 pre-roll can complete its whole lifecycle within a fraction of a second of startSession() returning, and recommends registering adbreakstatus/ad-event listeners before calling startSession rather than after; (3) packages/demo/docs/07-session.md confirms DAR's resume-only-with-explicit-resumeOffset behaviour 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 adssdk fleet selector tag. packages/test-app/src/runner/orchestrator.ts's selector-form device request adds tags: ['adssdk'] so runs use any lab device reserved for this project without needing to know installer IDs; documented in docs/e2e-device-runs.md ("Fleet tag: adssdk"). Directed installerId requests 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-app now requests devices by default via an allocator selector ({ platform: 'web', os, deviceName }, XAG-133/XAG-154) instead of a hardcoded installerId — 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: setting E2E_MAC_INSTALLER_ID / E2E_WIN_INSTALLER_ID pins 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-sdk bumped 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 via interceptManifestResponse + the shared injectPreRoll/injectPause helpers. 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 #0065a4 accent 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 under packages/demo/public/bell/, and a new public/no-login page in vite.config.js's SITE_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 delay capability 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 explicit Break detection label 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, credentials optiview / bumba), replacing the portal's old standalone password check. A new siteGatePlugin (packages/demo/vite.config.js) injects a head-time inline redirect script at build time on every page except a public-file allowlist (login.html plus 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 new packages/demo/src/site-gate.ts; the redirect target is sanitized (sanitizeNext) against open-redirect payloads. ?e2e=1 bypasses the gate for the demo's own automated tooling. Demo-app-only; no SDK/core change. New Playwright suite packages/demo/e2e/gate.spec.ts + pages.spec.ts (51 tests: gate redirects, login form, and one boot/lifecycle check per page) and unit tests packages/demo/src/__tests__/site-gate.test.ts. See packages/demo/docs/20-customer-demos.md ("The portal") and the root README.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 (pure renderExplainer + mountExplainer, mobile-responsive: diagrams scroll at a fixed readable width below 720px instead of shrinking illegibly) and explainer-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 one Ad Server (the Ads API's Notify arrow enters near the top-left, the Ads SDK's Ad call arrow 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 for Configure & 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 labelled Ad Insertion, Mgnt Dashboard/ADS Client API/the break-CDN renamed to Dashboard/Ads API/Ads SDK, Your Player + SDK simplified to Player, 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 vertical Content arrow 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), adds L-Shape Content (the backdrop-only lshape_content format) alongside L-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, so mountExplainer() 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.md documents the explainer contract for future customer demos. Demo-app-only; no SDK/core change.

Fixed

  • First-frame-gated pre-roll dropped adbreakbegin when 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 defers adbreakbegin until 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, so adbreakbegin was never emitted — yet aderror/adend/adbreakend still fired, producing an adbreakend with no matching begin. DolbyAds.emitAdBreakBegin() now emits adbreakbegin at most once per break and is flushed before any terminal event (aderror in the ad-error callback, adbreakend in onBreakEnd), so a gated pre-roll is always bracketed by adbreakbegin … adbreakend. Web (@dolby-ads/core) only — the first-frame gate is web-specific; the Kotlin/Swift cores emit adbreakbegin unconditionally at break start and the shared AdBreakSequencer already pins the canonical order (conformance fixture sdk-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's playerContainer, the bar kept its old z-index: 5, below the SDK's internal ad-overlay/companion layers (up to z-index: 101); the overlay stage painted over the bar so clicks landed on the invisible stage. Fixed by raising the bar's z-index to 200 (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) called showControls() from the adbreakstatus handler, 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 the adbreakbegin handler (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 double and lshape_ad pre-rolls on globoplay.html now 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's playerContainer config option or included the control bar inside it. Since the SDK repositions/resizes playerContainer into a pip corner for double and 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 #theoPlayerEl where used) in a dedicated #playerContainer div passed explicitly to DolbyAds({ playerContainer }), and moving the control bar / break toast to be direct siblings of the outer #container (matching the existing #container 16: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 existing playerContainer config 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-OUT break's start as the nearest #EXT-X-PROGRAM-DATE-TIME plus the #EXTINF durations of segments in between, but reset the pending #EXTINF duration 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 in parser.test.ts covering 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 timeupdate PDT interpolation computed firstFrag.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 every FRAG_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 made BreakScheduler consume 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 the adbreakstatus countdown; 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) HlsJsAdapter now 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 fixture wallclock-rewind-jitter-suppress. The PlayerAdapter contract doc now requires programDateTime to be position-anchored and continuous. Shaka's PDT (presentationStartTime + currentTime) was already correct.

Added

  • Configurable double-box audio focus (PLAYG-286). New doubleBoxAudio config option ('ad' | 'content', default 'ad') controls which side is audible during a double-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. AdPlayerController tracks the active break's audioFocus; the unified sdk.muted/sdk.volume (PLAYG-285) target the focused side only, and the existing content<->ad volumechange sync 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 and content focus, the muted setter'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. DolbyAds now exposes sdk.muted (boolean) and sdk.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 new volumechange event so a UI can keep a mute button's label correct without polling. Internally, AdPlayerController gains the same muted/volume API and a setVolumeChangeCallback hook; the existing content<->ad volumechange sync now also notifies this callback, and a VAST/IMA CSAI ad in progress is bridged via a new VastAdManager.setVolume() (mirroring the existing setMuted()) 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's onAdTimeUpdate, so AdBreakStatus.breakRemainingSec counts 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 ExoPlayer Handler poll of currentPosition/duration, iOS an AVPlayer.addPeriodicTimeObserver. For VAST/CSAI (IMA) assets a new VastAdEventCallbacks.onAdProgress(currentTimeSec, durationSec) is forwarded: on Android from the IMA AdVideoPlayer's existing 250 ms progress poll, on iOS (which has no per-frame IMA progress event) from a 250 ms timer anchored to IMAAd.duration at .STARTED and 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 consumes onAdTimeUpdate (PLAYG-276/277). The web E2E break-status scenario (packages/test-app) now also asserts the countdown ticks (breakRemainingSec decreases + ticking), and docs/e2e-test-plan.md + conformance/PENDING-PARITY.md record the native feed as done.
  • Native ad-break status API on Android and iOS/tvOS (PLAYG-276, PLAYG-277). The adbreakstatus event and getAdBreakStatus() 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 the AdBreakStatus model (phase idle/upcoming/active/complete, secondsUntilBreak, breakRemainingSec, adIndex, totalAds, adsRemaining, ticking, warningSec), an adbreakstatus event type + AdBreakStatusEvent, a breakWarnings config option plumbed into the core BreakScheduler, and the phase state machine + playback-gated countdown assembly ported 1:1 from the web DolbyAds.ts — including the PLAYG-266 no-rewind baseline (the countdown never jumps 15 → 14 → 15) and the wall-clock fallback that advances the countdown before real ad progress arrives. The wall-clock source is an injectable Clock so 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 the upcoming pre-break warning. The portable warning schedule stays conformance-locked by the existing break-warning fixture, so the 2026-07-02 row in conformance/PENDING-PARITY.md is 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 STARTED event, plus on container resize (PLAYG-275). Completes PLAYG-265. Calling AdsManager.resize() only before start() (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. VastAdManager now also calls resize() on the IMA STARTED event (the reliable point, once the renderer exists), and AdPlayerController calls vastAdManager.resize() from the container ResizeObserver so an active VAST ad stays filled through fullscreen / browser / split-screen resizes. Added unit tests for the STARTED-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.html in a real browser): (1) the break countdown (adbreakstatus.breakRemainingSec) was frozen because IMA never fires the ad video element's timeupdate that the media path relies on — VastAdManager now subscribes to IMA AD_PROGRESS and forwards currentTime (= ad duration − AdsManager.getRemainingTime()) via a new onAdProgress callback, which AdPlayerController.playVastAsset wires to onAdTimeUpdate so the countdown ticks; (2) the countdown jumped backwards (15 → 14 → 15): before the first ad progress the countdown advances via a wall-clock fallback, but onAdStarted baselined the ad against a stale activeBreakElapsedSec of 0, so the first AD_PROGRESS (currentTime≈0) collapsed elapsed back to ~0 and remaining jumped back up — DolbyAds.onAdStarted now captures the wall-clock-inclusive elapsed at ad start (new currentBreakElapsedSec() helper, also used by computeBreakRemainingSec()) so the ad-time countdown continues monotonically; (3) content played underneath the ad (its currentTime advanced 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 at playBreak while the app's loadContent() play() resolved ~1–2s later (after the HLS manifest loaded) with no guard — AdPlayerController now installs a break-scoped content gate (gateContentForBreak) that re-pauses content on any playing for the whole duration of a content-covering break, cleared at endBreak/abort/destroy before the resume play(). Content now stays gated during the pre-roll and resumes from the start. Regression tests added in AdPlayerController.vast.test.ts and DolbyAds.adBreakStatus.test.ts.
  • VAST/CSAI ads in preloaded double (and L-Shape) breaks now resize to their box instead of overflowing (PLAYG-265). The VastAdManager preloaded path (preloadVast() ahead of break start, then startPreloaded() at break start) initialized the Google IMA AdsManager with the ad video element's clientWidth/clientHeight from arming time — before applyLayout()/reapplyAdContainerLayout() sized the ad container for double/lshape_ad formats. The IMA slot was therefore much larger than the intended box, so the ad creative overflowed it. Added VastAdManager.resize() and call it in startPreloaded() before start() 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 existing PLAYG-242 re-stamp still applies. Added unit tests for VastAdManager.resize() and the preloaded double break 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 adbreakbegin until the ad's first frame renders, resolving that gate only from the media ad player's playing event (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's clientWidth/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, and adbreakbegin never fired — only the adbreakstatus badge showed (surfaced by the new PLAYG-266 countdown on the Bally's demo). AdPlayerController.playVastAsset now calls revealFirstFrame() 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 in AdPlayerController.vast.test.ts.
  • Main player page pre-roll injection used the wrong manifest field (PLAYG-264). injectPreRoll / buildPreRollBreak in packages/demo/src/presets.ts (used by the main Player page's interceptManifestResponse hook) built the injected break with a variants array, but the SDK's parsed Break carries a singular variant field (BreakVariant | BreakVariant[]) that selectVariant reads. Because interceptManifestResponse receives 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's ParsedBreak type and buildPreRollBreak to emit the singular variant, matching the customer-demo inject-ads helper. The preroll-injection.test.ts unit test now asserts the variant field the SDK actually consumes (it previously asserted the wrong variants shape, 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-screen single. 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 the logoOverlay manifest option / buildLogoOverlayBreak() (distinct break id ballys-overlay-logo) rather than a VAST pre-roll. ballys-manifest.ts gains BallysVastFormat (the linear subset), buildLogoOverlayBreak(), and a logoOverlay option; preRoll.format is narrowed to BallysVastFormat. Demo-app-only; no SDK/core/brain change. ballys-manifest unit 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-roll Overlay stays a pre-roll format (its injectPreRoll already supports the non-linear overlay), 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's PreRollOptions gains an optional overlay field (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 }, opacity 0.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 image object-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-ads unit 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 at docs.html#adbreakstatus, linked from a new How-To sidebar item) explains the adbreakstatus event / sdk.getAdBreakStatus() snapshot: what it is, the full AdBreakStatus field table and phases, how to subscribe/poll and drive a countdown from breakRemainingSec + ticking, how to configure pre-break upcoming warnings via breakWarnings: { seconds: [...] }, and how to use it for pre-rolls (no upcoming warning, ticking gates 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 assembleBallysManifestbuildPreRollBreak/buildPauseBreak (packages/demo/src/customer-demo/ballys-manifest.ts), GloboPlay via augmentManifestinjectPreRoll (already supported) and injectPause. The reusable injectPause/buildPauseBreak helpers in packages/demo/src/customer-demo/inject-ads.ts gain an optional delaySeconds (default 0) that sets the break's delay. Defaults stay 0, so existing behaviour is unchanged unless a delay is set. The SDK already honours Break.delay for position: 'pre' and position: 'pause', so this is demo-app-only; no SDK/core/brain change. inject-ads unit 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 with manifestBaseUrl = https://optiview-ads-manifest-phx-1.staging.dolbyio.com/manifest/v1 and started with channelId = 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 via interceptManifestResponse using the PLAYG-261 injectPreRoll/injectPause helpers, 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 in customer-demo/registry.ts so its portal tile appears. Guidelines doc gains a GloboPlay worked example (using globo.png for the pause illustration); README.md and 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, plus buildPreRollBreak/buildPauseBreak and the INJECTED_PREROLL_ID/INJECTED_PAUSE_ID guards) 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's interceptManifestResponse hook. injectPreRoll prepends a position: 'pre' break (formats single/double/lshape_ad, static or VAST, plus a non-linear logo overlay); injectPause appends a position: '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 under packages/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 .page grid, 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: CustomerBrand gains optional text, statusbarBg, cardBg, cardBorder, and optiviewLogoFilter tokens (exposed as --brand-text / --brand-statusbar-bg / --brand-card-bg / --brand-card-border / --brand-optiview-filter), --brand-accent may now be a CSS gradient (the status-bar accent stripe is painted on the border-box layer so gradients show there), and brandVars emits 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 adbreakstatus event and getAdBreakStatus() API give player UIs a single source of truth for break countdown. AdBreakStatus carries phase ('idle' | 'upcoming' | 'active' | 'complete'), secondsUntilBreak, breakRemainingSec, adsRemaining, adIndex, totalAds, and ticking. Configurable breakWarnings: { seconds: number[] } emits adbreakstatus with phase: '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 in conformance/PENDING-PARITY.md.
  • CSAI VAST ad preload (PLAYG-253). VastAdManager now supports two-phase loading: preloadVast(adTagUrl, options) requests the VAST tag and creates the IMA AdsManager without starting it, and startPreloaded() begins playback at break start. AdPlayerController.preload() routes VAST assets to preloadVastAsset(); playVastAsset() uses the preloaded manager when present and falls back to playVast() 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_TAG is now Google Ad Manager's live pod-serving endpoint https://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 (currentManifestOpts sets podDuration: true). vastTagForBreakDuration sets pmnd=0 and pmxd = the break duration floored to whole seconds (ms), so the pod fills up to the break with standard creatives. (Previously it set pmnd == 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 the assembleBallysManifest layer for non-pod tags.
  • Preload lead time increased to 8 seconds (PLAYG-257). BreakScheduler.PRELOAD_AHEAD_SECONDS is now 8 in all three cores (TS, Kotlin, Swift) so the IMA VAST round-trip has enough runway before a break starts.
  • BreakScheduler now respects onBreakApproaching callback acceptance (PLAYG-257). The callback may return false to 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. Updated DolbyAds (TS, Android, iOS) to return false when isAdPlaying() is true. Added conformance fixture preload-8s to 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-OUT with the nearest #EXT-X-PROGRAM-DATE-TIME and 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 #EXTINF advances it by the segment duration, and a #EXT-X-CUE-OUT's start is the clock at that point (PDT + intervening #EXTINF durations), 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, so Scte35ManifestStore'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-right wrapper (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.html loaded from libraryLocation; 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/' in packages/demo/src/player-factory.js; packages/demo/vite.config.js stages the installed build into packages/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 — the pmnd/pmxd ad-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) in packages/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 THEOplayer error/waiting/canplay/playing events 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's getLog); 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=), exposing start/stop/seek/setConfig/getState/getLog for 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 the docs/browserstack-web-e2e.md guide. The e2e/ folder is now only a local environment home: a rewritten e2e/.env.example (Xagget broker vars), e2e/.gitignore, and e2e/README.md pointing to the Xagget docs. Root package.json scripts e2e:*, test:e2e*, and related dependencies were dropped. The cross-platform E2E path is the Xagget harness in packages/test-app.
  • Remove the customer-onboarding capability (PLAYG-216). Removed the packages/onboarding package (demo generator, matrix runner, report, validation, templates, and tests), the .windsurf/workflows/onboard.md workflow, and the docs/customer-onboarding.md guide. All references in AGENTS.md, CONTRIBUTING.md, docs/jira-workflow.md, .windsurf/workflows/*.md, .devin/playbooks/develop.md, and packages/demo/docs/17-install.md were updated to remove the customer-sweep/BrowserStack mentions. The unrelated developer-tutor dolby-onboarding AI agent remains in packages/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 — its libraryLocation must match the running THEOplayer build). The ad-break manifest is generated entirely client-side, with no backend: 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, and returns a wallclock break manifest that is handed to the SDK via the interceptManifestRequest hook (returning a body short-circuits the network). A mid-roll ad-format selector (single / double / L-Shape) fills every break with a VAST ad, appending pmnd/pmxd ad-pod-duration query parameters (min/max, milliseconds) sized to the break via packages/demo/src/customer-demo/vast-pod.ts. To support this, the shared @dolby-ads/scte35-bridge parser 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, no DATERANGE) are handled. The whole manifest is assembled client-side by the pure, unit-tested packages/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's programDateTime). Start mutes THEOplayer and autoplays the stream (so muted autoplay is never blocked — which also lets THEOplayer expose EXT-X-PROGRAM-DATE-TIME and clears the non-fatal DA-PDT-MISSING warning), 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 the customer-demo/registry.ts registry, 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.ts renders 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-surface CSS custom properties (renderStatusBar, brandVars, mountCustomerShell, CUSTOMER_SHELL_CSS). New packages/demo/src/customer-demo/registry.ts is 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 (via interceptManifestRequest), 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 dvr feature is added to the E2E matrix (runner/matrix.ts) with three CLI-selectable variant cells (seekback-rearm, forward-no-replay, resume-seek-guard) and matching aliases in runner/scenario-id.ts. The scenarios are VOD/DAR/Static/single (dvr-*-static-single-vod-dar) so the seek command can deterministically drive the playhead on the PTS timebase: dvr-seekback-rearm asserts a completed mid-roll re-arms and fires a second lifecycle when the viewer seeks back before the break; dvr-forward-no-replay asserts a future mid-roll is skipped and never plays after the viewer seeks forward past it; dvr-resume-seek-guard uses scheduleBreak with resumeOffset: 0 to force the SDK's internal post-break resume-seek and asserts BreakScheduler.notifyResumeSeek prevents the just-completed break from re-firing. MockBreakStore now supports resumeOffset in ScheduleBreakParams/ScheduledBreakSummary and PlayerController.load pins MockBreakStore.setNow() so live break wallclock epochs are stable for dvr and other live scenarios. Docs (docs/e2e-test-plan.md) updated; unit tests added for runner/matrix.ts, runner/scenario-id.ts, and scenarios.ts.
  • E2E runner CLI flags and root npm run e2e shortcut (PLAYG-192). The Xagget E2E runner in packages/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 matching E2E_* env vars. Pure parsing logic is in packages/test-app/src/runner/cli-args.ts (unit-tested) and consumed by packages/test-app/src/runner/cli.ts. The root package.json adds "e2e": "npm run e2e -w @dolby-ads/test-app --" so npm run e2e -- --features preroll --adapters hlsjs --dry-run runs from the repo root. --help lists every flag and its valid values; --list is 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) and https://demo.unified-streaming.com/k8s/live/stable/scte35.isml/.m3u8 (DVR, real SCTE-35 markers) — replacing the self-hosted live-origin package, which has been removed. The new @dolby-ads/scte35-bridge package polls the DVR stream's #EXT-X-DATERANGE/#EXT-X-CUE-OUT markers (falling back to the bare #EXT-X-CUE-OUT + nearest #EXT-X-PROGRAM-DATE-TIME pairing when no DATERANGE is 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 via interceptManifestResponse before 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/scheduleBreak gain a forceError: '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 balanced adbreakbegin→aderror→adend→adbreakend lifecycle with DA-AD-PLAYBACK-ERROR), neg-gam-failure (loading with an invalid GAM customAssetKey fails the DAI session with DA-GAM-SESSION-FAILED and content plays through with GAM breaks skipped, no break ever attempted), and neg-vast-error (the broken VAST tag brackets cleanly with an aderror and a VAST error diagnostic). candidateScenarioIds aliases the matrix's negative feature 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's offsetSeconds now accepts negative values, scheduling a Live mid-roll that already started in the past (join-in-progress); load gains a tuneIn: { enabled?, minBreakDurationSeconds? } override threaded into DolbyAdsConfig.tuneIn, and the adbreakbegin event's tuneIn: { 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, a tuneIn detail on adbreakbegin, presentation for the remainder rather than the full duration, and content resume), tunein-live-under-min-static-single-live-dar (same in-progress break, but tuneIn.minBreakDurationSeconds raised 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: new live-behaviors (variant-axis fan-out: tunein-remainder/tunein-under-min/chained-adjacent/chained-gap), break-cut, midroll-double, and pause-variants (variant-axis: duration-cap/video-asset) features in runner/matrix.ts and their aliases in runner/scenario-id.ts, plus a new E2E_VARIANTS selection env var — so every fixed-name coverage scenario in the suite (previously only reachable via the manual MCP loop) is now selectable through npm run e2e/E2E_FEATURES like every other cell. chained-live-adjacent-static-single-live-dar and chained-live-gap-static-single-live-dar (two mid-rolls with a 0s or 1s gap; asserts both lifecycles fire in order with no content playing event 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_ad formats), 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 second adbreakbegin/adbreakend and leaving the break "active". The BreakScheduler gains notifyResumeSeek(target, breakEnd), which the runtime (AdPlayerController.applyResumeSeek → new AdPlayerCallbacks.onContentResumeSeekDolbyAds) 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, or lshape_ad — the ad video rendered smaller than the correctly-sized box drawn for it. The VAST path in AdPlayerController.playVastAsset handed the ad <video> element to VastAdManager without any sizing CSS, so VastAdManager.slotWidth()/slotHeight() read the element's default clientWidth/clientHeight (or the 640×360 fallback) and passed those undersized dimensions to IMA's AdsRequest/AdsManager.init(...). The VAST path now re-stamps the element with width:100%;height:100%;object-fit:contain before 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 /develop workflow (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 guidelines section, 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-server now expire 2 hours after they were last fetched via GET /manifest/v1/:orgId/channels/:channelId. POST seeds 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, and GET/list also evict expired entries lazily. TTL and sweep interval are configurable when embedding ManifestStore or createBreakManifestApp.

[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 default manifestBaseUrlDEFAULT_MANIFEST_BASE_URL (TS @dolby-ads/core), DEFAULT_MANIFEST_BASE_URL (Kotlin android/dolbyads-sdk), defaultManifestBaseUrl (Swift ios/DolbyAdsSDK) — now points at https://optiview-ads.cdn.sneezysparrow.com/manifest/v1 (was https://optiview-ads.sneezysparrow.com/manifest/v1), following the Optiview Ads backend's domain move to a CDN subdomain. Integrators who pass an explicit manifestBaseUrl are 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 wallclock timebase the BreakScheduler previously matched a break's ISO start against the real system clock, which (a) diverged from the manifest spec and the PlayerAdapter contract — both say wallclock breaks match on EXT-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's programDateTime for the wallclock timebase (e.g. THEOplayer's player.currentProgramDateTime, already surfaced by every adapter), falling back to the injected clock with a one-shot DA-PDT-MISSING diagnostic 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 the pts timebase (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-step programDateTime to 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 via adPlayerFactory (e.g. a THEOplayer ChromelessPlayer adapter), destroy() removed the overlay element and cleared references but never called the adapter's own destroy(), 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 awaits adPlayer.destroy() (errors logged, teardown never blocked); the harness (packages/test-app/src/adapters.ts) also awaits THEOplayer's async destroy() for both content and ad players. Unit-tested. Portable-brain note: web-runtime-only (AdPlayerController is 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/play commands unbounded — if a command hung (as in the THEO deadlock above), the whole run_scenario hit the transport cap with zero context. New invokeBounded() (packages/test-app/src/scenarios.ts) races each command against a commandTimeoutMs watchdog (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 adds e2e/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 loadContent THEO path resolved only on the playing event. 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 every playing, so the event may never fire — the load command hung to the run cap (the single/lshape_ad share of the THEO failures). loadContent now resolves on the first of canplay/playing and rejects on error. 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 libraryLocation can no longer drift from the bundled player (PLAYG-220). THEOplayer resolves its runtime workers (e.g. the HLS/TS transmuxer THEOplayer.transmux.*) from libraryLocation at play time, and the version there must match the bundled player. The test-app hardcoded theoplayer@11.4.0 on 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 exported version (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() hardcoded type: '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-only lshape_content was unaffected). The source type is now inferred from the URL extension via inferSourceType() (.mp4/.m4vvideo/mp4, .webmvideo/webm); everything else — HLS playlists and extensionless GAM pod URLs — keeps the HLS type, preserving the original GAM behaviour.

  • E2E harness: lshape_content backdrop no longer renders a broken image (PLAYG-207). The SDK renders the lshape_content backdrop by setting assets[0] as an <img src>, so the asset must be a static image. The static mock supplied the primary video (SAMPLE_AD_MP4) as assets[0], producing <img src="…orange-aid-pause.mp4"> — a broken-image icon behind the content pip. buildVariant now supplies a static IMAGE backdrop (SAMPLE_COMPANION_IMG) for lshape_content, matching the SDK's <img> render path. On top of that, SAMPLE_COMPANION_IMG itself (the Google-hosted sample PNG also used for double/lshape_ad companions) 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 — renderLshapeContentBackdrop assumes 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-forget void destroyPlayer(...), so the next load built a new shaka.Player on 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 prior destroy() finish, so the following cell recovered). teardownSdk() is now async and load() 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_content scenario asserts the correct backdrop-only lifecycle (PLAYG-207). lshape_content shows branded content for the whole break and plays no individual ad, so it emits only adbreakbegin → adbreakend (no adbegin/adend). defineBreakLifecycleScenario previously asserted the full ad lifecycle for every experience and so wrongly failed all lshape_content cells. It now derives the expected event subsequence per experience via the new expectedLifecycle() (packages/test-app/src/scenarios.ts) — backdrop-only for lshape_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() read DIAGNOSTIC_CODES[code].level/.category without checking the code exists, so calling it with an unrecognised code threw TypeError: Cannot read properties of undefined (reading 'level'). On the ad-playback path that TypeError was caught and surfaced as a spurious aderror with that confusing message. diagnose() now falls back to level info / category unknown for 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 current main — the exact preroll-static-overlay-vod-dar cell runs the full adbreakbegin → adbegin → adend → adbreakend lifecycle 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 new scripts/build-test-content.sh, /deploy step 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 new live-content ffmpeg service (deploy/services/docker-compose.services.yml) writing straight into the demo webroot (/test-content/live/main.m3u8). Both use a testsrc moving-clock pattern (yuv420p) and are served with Access-Control-Allow-Origin: * (nginx /test-content/ block on the server). Also bumps @xagget/device-sdk 0.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 by installerId and 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: requestDevice is retried up to E2E_DEVICE_RETRIES times (default 3) before a platform's cells are marked errored. 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_RETRIESE2E_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_URL in @dolby-ads/test-app switched from the MUX x36xhzz TS 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 adend for 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 emitted adbreakend without a preceding adend — an unbalanced adbegin (adbreakbegin → adbegin → adbreakend). This was a portable-brain bug in AdBreakSequencer: the break-elapsed (and backdrop-elapsed) outcome finalized with an empty prefix, dropping the in-flight asset's adend. The sequencer now tracks whether an asset is in-flight (adbegin emitted, not yet settled) and, on a cut, emits that asset's adend before adbreakend — 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 no adend; the lshape_content backdrop cut still emits no per-ad events. On web, the overlay break-cut timer now routes through the sequencer's break-elapsed outcome instead of calling endBreak() directly (guarded like onAdEnded so it cannot double-emit against a natural end or the PLAYG-217 GAM pod-end). The native runtimes already route the cut through BREAK_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 (a vendor: '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 emitted adbegin without a matching adend (adbreakbegin → adbegin → adbreakend). The ad player's timedmetadata is now forwarded to GamStreamManager.processTimedMetadata (web AdPlayerController, Android OverlayAdRenderer; iOS already auto-reads ID3 via IMAAVPlayerVideoDisplay), and IMA's pod-level AD_BREAK_ENDED drives the public adend — yielding the balanced adbreakbegin → adbegin → adend → adbreakend lifecycle across all three cores (one adbegin/adend per 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 in conformance/PENDING-PARITY.md. Related: PLAYG-215 (break-cut duration cutoff also drops adend).

Changed

  • E2E: bump @xagget/device-sdk 0.7.0 → 0.7.2 (PLAYG-203). Picks up the transport's runtime-aware broker defaults (XAG-64) and a more robust mqtt connect resolution. No public-API changes. Two test-app adjustments: (1) the Vite mqtt→ESM alias is still required — even with 0.7.2's broader connect probing, Vite resolves bare mqtt to mqtt@5's browser-UMD build whose interop hides a usable connect, so the agent fails to start without the alias; (2) the play command now swallows the benign play()-interrupted-by-pause() AbortError (a pre-roll pauses content immediately after starting it) via isPlayInterruptedByPause, 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 shared resolveCells({ feature: ['preroll'] }) matrix and registers one preroll-<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 reporting scenario-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's run_scenario params (ctx.params.adapter), letting cells that differ only by adapter share one on-device scenario. New prerollBinding() 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 (demo networkCode/customAssetKey from mock-manifest.ts); both, plus the VOD URL, are overridable via RegisterScenarioOpts. The S4 seed alias break-lifecycle-static-single-vod-dar is 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 stale adbreakend; (2) index.html now loads the Google IMA SDKs (ima3.js before ima3_dai.js) — without them VAST + GAM broke with aderror; (3) the GAM mock vendorParameters now carry the full EABN pod shape (type:'pod' + eabnVersion) so isGamVendorParameters accepts them (else the pod resolved to no URI). Scenario failure messages now include the aderror detail + diagnostic codes for triage. Validated green on hlsjs · MAC/Chrome: Static · single × {VOD-DAR, VOD-DAI, Live-DAR} and VAST · single · VOD-DAR. GAM preroll is bug-filed PLAYG-217 — DAI engages and plays a real ad but emits adbegin without adend (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 = first DA-* token in the message, else UNKNOWN) — so repeated failures collapse into one candidate with N occurrences, and emits the exact Jira Bug title/body (body carries an E2E-SIGNATURE marker for cross-run dedup) plus a ready-to-run searchJql. npm run e2e prints a Triage block after the roll-up and writes candidates to E2E_TRIAGE_JSON when set. Because there is no Jira client in-repo, the search-before-file (occurrence comment vs new PLAYG Bug) and XAG [xagget] routing for framework defects are the documented agent step in the new docs/e2e-bug-triage.md how-to (linked from /run-e2e Step 6, .devin/playbooks/run-e2e.md, docs/e2e-test-plan.md, docs/e2e-device-runs.md, and AGENTS.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-e2e drives 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 every TestReport into a pass/fail roll-up (runner/report.ts). The live @xagget/publish + @xagget/runner-sdk driver (runner/xagget-driver.ts) is loaded lazily from optional deps, so build/test/typecheck pass without them. New npm run e2e -w @dolby-ads/test-app (CLI reads E2E_FEATURES/E2E_ADAPTERS/E2E_PLATFORMS/… plus E2E_DRY_RUN, E2E_TIMEOUT_MS, E2E_REPORT_JSON). The /run-e2e workflow is repurposed from the mock-backend/Playwright flow to drive this runner interactively, the /run-tests E2E section now points at it, and a new .devin/playbooks/run-e2e.md mirrors it for Devin. Docs: docs/e2e-test-plan.md (selection/parallelism now reference the runner), docs/e2e-device-runs.md (the npm run e2e loop), 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-browserstack workflow 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 → single only, 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-single and midroll-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 from AGENTS.md, docs/e2e-xagget.md, docs/e2e-device-runs.md, and the /run-e2e workflow. Docs-only — no code/SDK change.
  • E2E: first on-device Xagget scenario (PLAYG-203, S4 of PLAYG-192). packages/test-app now registers an on-device scenario (src/scenarios.ts) via the device agent's registerScenario. The parameterized break-lifecycle-static-single-vod-dar smoke (hls.js · Static · single · DAR · VOD pre-roll) drives the app through its own commands (scheduleBreakloadplay) and asserts the full ad-break lifecycle adbreakbegin → adbegin → adend → adbreakend plus content resume, observed via the getState event timeline (so it depends only on ctx.invoke and 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_buildrequest_devicerun_scenario): TestReport.status: passed. Three test-app fixes were needed to get the on-device run green: (1) Vite now aliases mqtt to its ESM browser build (dist/mqtt.esm.js) so the device SDK's require("mqtt") resolves connect in the bundle; (2) the hls.js ad-adapter factory uses the SDK's RoutingAdAdapter so a progressive-MP4 ad creative routes to native <video> instead of failing in hls.js; (3) the Static creative is a ~12s video/mp4 (shorter than the 15s break) so the ad ends naturally — a creative longer than the break is currently cut off without an adend (unbalanced adbegin), 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-app now ships a reusable fixture layer (src/fixtures.ts) that drives ad breaks entirely through the SDK manifest-interception API (interceptManifestRequest short-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 a BreakManifest that is validated by the SDK's own parseBreakManifest, so a malformed fixture fails at author time. VOD uses the PTS timebase and Live uses wallclock (absolute ISO break starts). GAM fixtures emit a vendor:gam / pod asset with a client-side randomized podId and the demo network code / custom asset key, flagged onlineToIma (Optiview-free but online to Google IMA DAI for ad-fill). The scheduleBreak command now accepts a contentType (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= via resolveBootstrap(), advertises deviceOnline on start(), and registers JSON-Schema'd driving commandsload, 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's interceptManifestRequest hook (no Optiview backend) for static / vast / gam sources (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 existing interceptManifestResponse still runs afterward. A throw is non-fatal: the SDK emits the new DA-MANIFEST-REQUEST-INTERCEPT-FAILED diagnostic 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/core ManifestService), Android (HttpManifestSource + ManifestFetcher headers, suspend), iOS (URLSessionManifestSource, async throws) — where the native cores forward the config hook to the injected ManifestSource. Locked by the cross-language manifest-request-interception conformance 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, parsed BreakManifest (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 new DA-MANIFEST-INTERCEPT-FAILED diagnostic and falls back to the un-modified manifest. Implemented across all three cores with identical behaviour — web (@dolby-ads/core ManifestService), Android (HttpManifestSource, suspend), iOS (URLSessionManifestSource, async throws) — where the native cores forward the config hook to the injected ManifestSource (default sources apply it after parsing). Locked by the cross-language manifest-interception conformance fixture (parse → intercept → use) passing on all three cores. Demo: the Player page's Pre-roll feature now uses this hook instead of the old window.fetch monkey-patch (retired preroll-interceptor.ts; injectPreRoll now operates on the parsed manifest). Docs: configuration, manifest, and diagnostics pages + top-level docs/ and README.
  • Ad format on break & ad events (PLAYG-182). Every break/ad-scoped SDK event now carries a format field — 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/coreAdBreakBeginEvent/AdBreakEndEvent/AdBeginEvent/AdEndEvent/AdErrorEvent/the quartiles/AdTimeupdateEvent, plus a new exported resolveBreakFormat(break) helper and format on the portable AdBreakSequencer step), Android (event.format: BreakFormat?), and iOS (event.format, plus the format enum 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-sourced aderror/waiting/playing (no break). The conformance harness pins format on every sdkSequence step (new sdk-overlay-lshape-ad and sdk-overlay-format fixtures; 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 ?presets URL 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: adds encodeUrlPresetsSafe() (base64url) to packages/demo/src/demo-presets.ts and a new unit-tested pure helper packages/demo/src/preset-builder-model.ts (PRESET_FIELDS / buildPreset / presetToValues / presetsToUrl / urlToPresets); the page itself is preset-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 — only name is 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 ?presets the 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 helper packages/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 the lshape_ad companion. 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, but destroyContentPlayer() was called synchronously and initSdk() immediately attached a new HLS.js instance to the same element — Shaka's late detach then clobbered HLS's freshly-attached MediaSource. destroyContentPlayer() is now async and awaits shaka.Player.destroy(), and initSdk() 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 preRoll left 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. applyPreset now treats pre-roll as a full on/off toggle — an omitted (or enabled:false) preRoll turns it OFF — so a preset never inherits another's pre-roll injection. Covered by new demo-presets regression 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. AdPlayerController now exposes stopActiveBreak() (pauses the ad media, clears the break-cut timer, tears down the overlay/layout, and resets break state without resuming content), called from DolbyAds.endSessionInternal() so both endSession() and destroy() abort an in-flight break; AdPlayerController.destroy() also pauses the ad player defensively. Covered by new AdPlayerController.teardown and DolbyAds.scheduling teardown 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-await let the stale continuation drive the new player and stamp an out-of-date top-bar status. Added a monotonic load-generation token (bumped in initSdk(), captured per load) that makes every superseded continuation bail; initSdk() now also clears any lingering ad-break countdown/toast, and each load shows a Loading… 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's mediaType (image/video) × type (static/vast) selects the creative: a static image URL, a VAST CompanionAds StaticResource image, a static MP4 URL, or a VAST Linear progressive MediaFile. Brain (PLAYG-127): PauseAdController resolves the generic pause format and carries mediaType through to the renderer; conformance fixtures cover image+video × static+vast and all three cores pass. Parser (PLAYG-128): a new dependency-free parseVastLinearMediaFile / VastLinearParser (TS/Kotlin/Swift) extracts the first progressive video/mp4 MediaFile plus VideoClicks/ClickThrough and Impression beacons. Renderers: the web AdPlayerController (PLAYG-129), Android OverlayAdRenderer (PLAYG-130), and iOS OverlayAdRenderer (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 sample public/pause-linear-vast.xml. The render-skip diagnostic is unified to DA-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 redacted exportDiagnostics() report and the @dolby-ads/mcp analyzeDiagnostics() root-cause/findings into a pre-filled Jira Create issue screen for the PLAYG project (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's sessionStorage as 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 helpers packages/demo/src/bug-report.ts (buildBugReport / buildBugReportBody / buildJiraCreateUrl) and bug-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 helper packages/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) and pause-ad-vast (image via a VAST CompanionAds tag served by the mock at /vast/companion.xml) — are keyed off a new expect.rendering.pauseAd flag. 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-SHOWNDA-PAUSE-AD-DISMISSED): the web runner pauses the content <video> (runPauseAdScenario), the Android driver calls a new MainActivity.e2eSetContentPaused hook (ScenarioInstrumentedTest.awaitPauseAdLifecycle), and the iOS driver taps new hidden e2e-pause/e2e-resume accessibility controls that bridge to DemoController.e2eSetContentPaused (ScenarioUITests.runPauseAdScenario). The mock backend grows a companion-VAST endpoint and pause_image/pause_image_vast media variants. E2E/demo only — no SDK/core/brain change (the portable PauseAdController is 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 + VastCompanionParser are now mirrored in DolbyAdsCore, the Swift conformance CLI runs the pause-ad-* fixtures, and all three cores (TS/Kotlin/Swift) pass them (npm run conformance → 111/111; pendingFixtures skip removed from the Swift core). BreakFormat.pauseImage and the pause PlayerAdapterEvent are added to the Swift core; AVPlayerAdapter emits pause/playing from timeControlStatus. DolbyAds (Apple SDK) constructs the SGAI-only PauseAdController, feeds it the manifest, ticks it on the scheduler ticker, and bridges its show/hide to the renderer. OverlayAdRenderer gains showPauseAd/hidePauseAd: resolves the image (static URL, or fetch + parse VAST CompanionAds), 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 companion creativeView impressions, and supports companion click-through (UIApplication.open, iOS). Covered by PauseAdControllerTests + VastCompanionParserTests (swift test) and OverlayAdRendererPauseTests (xcodebuild test on 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 PauseAdController brain + conformance landed earlier). ExoPlayerAdapter emits the pause/playing events (via onPlayWhenReadyChanged) that drive the brain; DolbyAds (Android SDK) constructs the SGAI-only PauseAdController, feeds it the manifest, ticks it on the scheduler ticker, and bridges its show/hide decisions to the renderer. OverlayAdRenderer gains showPauseAd/hidePauseAd: it resolves the image (static URL directly, or fetch + parse VAST CompanionAds via the new dependency-free VastCompanionParser in dolbyads-core, the Kotlin mirror of the web parseVastCompanion), draws a scrim + image with a centered resume button and a top-right close button (both resume content), fades in/out, fires companion creativeView impressions, and supports companion click-through. New DA-PAUSE-AD-NO-IMAGE diagnostic on the render path. Covered by VastCompanionParserTest (JVM) and OverlayAdRendererPauseTest (Robolectric). Swift remains a tracked conformance parity gap (PLAYG-110).

  • Demo: Dolby favicon (PLAYG-119). Added a Dolby favicon.ico to packages/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.fetch interceptor that injects a position: "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 helpers injectPreRoll / buildPreRollBreak in packages/demo/src/presets.ts and installPreRollInterceptor in packages/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 delay parameter 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), so updateManifest() re-added it as PENDING and the pre-roll re-fired (its only gate is elapsed >= delay, permanently true once the delay has passed). The scheduler now keeps a session-scoped completed-break ledger: completeBreak() records the id and updateManifest() never re-adds a break that already played (cleared on destroy()). This also hardens mid/post-rolls against the same re-add path. Covered by a new BreakScheduler.preRoll regression 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 a 1fr grid item in .layout whose default align-items:stretch stretched 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 default min-width:auto, derived a huge minimum width (height × 16/9) that overrode max-width:1200px and overflowed off-screen (the SDK's dolby-stage then faithfully mirrored the oversized container). Fixed in the demo layout with align-items:start on .layout and min-width:0 on 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:0 pre-roll covered it. The SDK only paused content reactively at break start, so the customer's play() (after startSession resolves) 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 via AdPlayerController.holdContentForPreRoll(): it pauses the content player and re-pauses on any playing event until the pre-roll begins (which then owns the pause/resume) or the session ends. Scoped to content-covering formats (single/lshape_ad); delay>0 pre-rolls play content first, unchanged. New DA-PREROLL-CONTENT-HELD diagnostic. 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 during snapback breaks), and are clamped to [0, duration] (the upper clamp is skipped for live/unknown durations). A new unit-tested helper packages/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 with VAST_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-localhost page 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-hosted public/sample-vast.xml + public/sample-ad.mp4 and 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 package exports map to dist/, which is absent until @dolby-ads/core is built. The dev plugins now pass TSX_TSCONFIG_PATH=tsconfig.dev-cli.json so tsx resolves @dolby-ads/core/* from TS source (mirroring the demo's Vite browser-bundle aliases), letting npm run dev work without a prior build. Dev-only — production deployment still builds dist via 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() sent SIGTERM but 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 a SIGKILL fallback) and start/stop await 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 (default https://ads-sdk.xnappet.live) and org ID (default 0bda787b-…) are pre-filled under a collapsible Advanced section. Pasting a full channel URL auto-fills host/org/channel. New unit-tested helpers in packages/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.ts provides formatPlayerTime() (m:ssh:mm:ss past an hour; 0:00 for unknown/non-finite) and createTimeIndicator(), which polls the active PlayerAdapter.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.md documents 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, the e2e/ onboarding mode), the standard branded demo design (template pinned to the published artefact tarballs), how to file an onboarding task (the fenced ```onboarding config contract + a copy-pastable example), the /onboard pipeline (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.md gains an **Onboarding mode (customer-parameterized run)** section (playwright.onboarding.config.ts + E2E_ONBOARDING_DEMO_DIR + onboarding.spec.ts, driven by runMatrix), 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/v1 in 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). Adds SAMPLE_VAST_TAG_THEO, VAST_FORMATS, and buildVastManifest() to packages/demo/src/presets.ts; the page loads ima3.js. Demo only — no SDK/core/brain change.
  • /onboard workflow — customer onboarding orchestration (PLAYG-37 epic / PLAYG-73). New .windsurf/workflows/onboard.md sequences the onboarding pipeline end-to-end from a Jira task: read the task + parse its fenced onboarding config (parseConfigFromIssue, echoing only redactConfig), move the ticket In Progress, build the branded demo (buildWebDemo), run the real source + GAM matrix (runMatrix, one BrowserStack session at a time — or dryRun when 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 via scripts/jira.sh (attach/comment/transition). Unlike /develop it 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.mjs at the repo root — must live in the checkout so the @dolby-ads/onboarding workspace 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/onboarding gains a report renderer — renderReportMarkdown / renderReportHtml / writeReport({ config, matrix, sdkVersion?, demoArtifact? }, outDir) — that turns a runMatrix MatrixResult + the customer config into a Markdown comment body and a self-contained HTML artifact (<slug>-onboarding-report.{md,html}). Both render the config through redactConfig (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 the passed/failed/skipped summary; customer-supplied values are HTML-escaped. New scripts/jira.sh (a bb.sh-style curl helper: creds from gitignored .env.local, base from JIRA_BASE_URL, bounded curl, python3-built JSON) exposes comment <key> <file> (v2 plain-text body — Jira auto-links URLs), attach <key> <file>... (X-Atlassian-Token: no-check multipart), and transition <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 /onboard workflow (PLAYG-73). Offline unit tests cover the renderer including the redaction guarantee; jira.sh is a network helper exercised live (like bb.sh).
  • Customer matrix runner — real source + GAM DAI (PLAYG-37 epic / PLAYG-71). @dolby-ads/onboarding gains runMatrix(config, opts), which runs the generated, prebuilt customer demo (real contentUrl + 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-16 e2e/ harness via a new onboarding mode: e2e/playwright.onboarding.config.ts serves the static prebuilt demo (E2E_ONBOARDING_DEMO_DIR) with no mock/stitcher, and e2e/web/onboarding.spec.ts asserts boot (sdk-initialized) → content plays → (GAM break → resume, no stall) → full SDK lifecycle via the demo's window.__dolbyE2E hook (aderror forbidden). The shared hook helpers are extracted from runner.ts into e2e/web/hookAssertions.ts (no behaviour change to the existing suite). runMatrix derives a one-platform config per session from browserstack.yml (single source of truth), spawns the BrowserStack SDK with --config=playwright.onboarding.config.ts, and returns a structured MatrixResult (per-platform pass/fail + scraped BrowserStack build/session URLs + a preserved per-platform Playwright report) for the PLAYG-72 report; native families are reported as deferred. 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, and dryRun.
  • AI features-overview reference (PLAYG-79 / PLAYG-83). New packages/sdk/ai/reference/features-overview.md covering 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 in initAi.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 into docs.html nav 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 + the DA-VAST-* diagnostics). The Manifests page gains a VAST — CSAI (IMA sample tag) preset (vast-csai in packages/demo/src/presets.ts; PresetAsset extended with type: 'vast' + mimeType) that builds a vast pre-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 loads ima3.js from S6) to watch a real CSAI VAST ad. VAST is also now documented in docs/architecture.md (new CSAI section), the root README.md (ad-assets note + VastAdManager in the Android/iOS runtime rows), the native core READMEs (resolveVastAdmission guard), and packages/demo/docs/12-manifest.md (Try-it pointer); all aligned with docs/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 new vast* break formats in buildVariant. Three shared scenarios (e2e/scenarios/): vast-single and vast-lshape-ad (playout, full tier — real Google IMA ima3.js, opt-in like gam), and vast-unsupported-overlay (minimal tier, deterministic rejection — the renderer's format guard fires before IMA, asserting aderror, no adbegin, and the DA-VAST-UNSUPPORTED-FORMAT diagnostic). A new expect.diagnostics assertion field on the shared scenario model lets a scenario require coded diagnostics; it is checked via the window.__dolbyE2E hook (web) and the demo event log (Android/iOS), wired into all three runners (e2e/web/runner.ts, Android ScenarioInstrumentedTest, iOS ScenarioUITests). 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-demo skill 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 *.md alone. The scaffold_quickstart MCP tool is reframed as an optional accelerator. dolby-onboarding/AGENT.md step 3 and dolby-troubleshooter/AGENT.md now explicitly state MCP is optional.
  • Removed the internal dolby-e2e-run skill from shipped artifacts (PLAYG-79 / PLAYG-82). Deleted packages/sdk/ai/skills/dolby-e2e-run/; dropped the companion-skill bullet in .windsurf/workflows/run-e2e.md; cleaned the CHANGELOG reference. initAi.test.ts now asserts its absence.
  • Clearer step-by-step getting-started docs (PLAYG-79 / PLAYG-84). packages/demo/docs/02-getting-started.md reworked into an explicit 9-step numbered guide (install → HTML → wire adapter → create SDK → events → session → play → verify diagnostics → clean up). 01-overview.md updated to clarify MCP is optional.
  • Dedicated AI Assistance sidebar page + moved scaffold card (PLAYG-79 / PLAYG-85). packages/demo/docs/14-ai-assistant.md reworked 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" in docs.html (id ai-assistant kept 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.md AI section updated to clarify MCP is optional and renamed to "AI Assistance".

Fixed

  • Traefik /manifest router masked the demo's /manifests.html page. The backend-services router used PathPrefix(/manifest), which also matched the static demo page /manifests.html and routed it to the break-manifest-server (404) instead of nginx. deploy/services/docker-compose.services.yml now uses PathPrefix(/manifest/) (trailing slash), which captures only the API namespace /manifest/v1/... and leaves /manifests.html to nginx. The live server was patched directly; this aligns the canonical compose so the next scripts/deploy-services.sh does 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, and manifests.html had 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 Vite docsPlugin via a per-page <!-- SIDEBAR:<key> --> placeholder, so all app-shell pages share one definition (and the AI Assistance link). docs.html keeps 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 local break-manifest-server on localhost: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 new packages/demo/src/manifest-config.ts resolver (resolveManifestServerConfig) now points the pages at the hosted server on the same origin at /manifest/v1 in production (no local-server spawn, host-agnostic) while keeping the localhost:4100 dev-server flow for npm 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.sh now builds the stitcher + break-manifest-server images with --platform ${TARGET_PLATFORM:-linux/amd64} (and the compose pins platform: linux/amd64) instead of the build host's native arch. An arm64 (Apple Silicon) build shipped to the amd64 server crash-looped with exec /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 on ads-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). New Dockerfile per 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 no node_modules. The break-manifest-server CLI gains PUBLIC_HOST/--host so the channel URLs it returns are rooted at the public origin (it otherwise defaulted to localhost). Canonical compose + samples live in deploy/services/ (docker-compose.services.yml, .env.sample, stitcher.channels.sample.json); the live copy lives on the server in /srv/dolby-ads-config/. New scripts/deploy-services.sh ships images registry-free (docker save | ssh docker load) and runs docker compose up -d (user-approved); /deploy gains 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/iOS OverlayAdRenderer) — are extracted to a new portable resolveVastAdmission brain unit mirrored across all three cores (TS reference @dolby-ads/core, Kotlin dolbyads-core, Swift DolbyAdsCore) 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-linear overlay/lshape_contentDA-VAST-UNSUPPORTED-FORMAT, else admitted (no new diagnostic codes). A new vastAdmission conformance 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 pass ssai: 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 under https://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 (DolbyAdsSDK needs DolbyAdsCore; DolbyAdsRuntime needs DolbyAdsCore + DolbyAdsSDK + the Google IMA SPM package). New scripts/build-artifacts-ios.sh temporarily switches all SwiftPM library products to type: .dynamic (committed Package.swift files restored) so inter-module deps link dynamically (@rpath/…framework) rather than statically embedding each other, runs xcodebuild archive per destination, copies the emitted .swiftmodule into each framework's Modules/ (SwiftPM's archived framework omits the module interface, which otherwise breaks import), then xcodebuild -create-xcframework → zip → swift package compute-checksum, and emits manifest.json (per-product url + SHA-256) plus a browsable index.html with a Package.swift snippet. Verified consumable by building throwaway SPM consumers against the produced xcframeworks. Artefacts stage under packages/demo/dist/artifacts/ios/ so they ride the existing /deploy rsync (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 (DAR replacement / DAI insertion), 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 chosen adInsertionType, so the DAR (skip replaced window) vs DAI (resume at cue) resume behaviour is observable on the mid-roll. A new buildVodManifest(format, midrollStart) helper (+ DEFAULT_VOD_CONTENT_URL) is added to presets.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 under https://ads-sdk.xnappet.live/artifacts/android/maven/, consumable via a single Gradle maven { url … } entry. The root android/build.gradle.kts applies maven-publish to those modules (Android release variant for dolbyads-runtime, java component for the JVM libs, each with a sources jar), publishing to packages/demo/dist/artifacts/android/maven so the repo rides the existing /deploy rsync. Published POMs reference the sibling com.dolby.ads:* modules, so the one repo resolves the whole graph (third-party Media3/IMA/coroutines deps come from google()/mavenCentral()). New scripts/build-artifacts-android.sh (version tracks lockstep lerna.json via -PdolbyAdsVersion; output override ANDROID_MAVEN_DIR) + a browsable index.html; /deploy and 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). vast assets 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. New DolbyAdsRuntime VastAdManaging protocol + real ImaVastAdManager drives IMAAdsLoaderIMAAdsRequest(adTagUrl:)IMAAdsManager (rendered inside the overlay IMAAdDisplayContainer), distinct from the DAI pod-serving GamStreamManager (IMAStreamManager/IMAPodStreamRequest). OverlayAdRenderer routes VAST assets to it, emitting the normal adbegin/quartile/adend/adbreakend sequence and resuming content. Same guards/diagnostics as web/Android: non-linear overlay/lshape_contentDA-VAST-UNSUPPORTED-FORMAT; IMA SDK unavailable → DA-VAST-IMA-SDK-MISSING; IMA load/play failure → DA-VAST-IMA-ERROR (all also raise aderror and recover); a break-cut Task.cancel() propagates as cancellation (not an error). New renderer diagnose seam (AdRenderer.setDiagnoseHandler, default no-op, wired from DolbyAds) so renderer-side DA-VAST-* codes flow through the SDK diagnostic stream. The CSAI engine sits behind an injectable factory so xcodebuild test units 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 into npm pack tarballs hosted under https://ads-sdk.xnappet.live/artifacts/web/, installable directly via npm install <tarball-url> with no private registry. A new scripts/build-artifacts-web.mjs (root npm 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 a manifest.json (versions + SHA-256 + npm integrity) plus a browsable index.html. Artefacts stage under packages/demo/dist/artifacts/web/ so they ride the existing /deploy rsync (no --delete regression); the /deploy workflow gains the build + a manifest.json verification 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). vast assets 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. New dolbyads-runtime VastAdManager (interface + real ImaVastAdManager) drives AdsLoader/AdsManager/AdDisplayContainer plus a VideoAdPlayer backed by the SDK's ad ExoPlayer (distinct from the DAI pod-serving GamStreamManager). OverlayAdRenderer routes VAST assets to it, emitting the normal adbegin/quartile/adend/adbreakend sequence and resuming content. Same guards/diagnostics as web: non-linear overlay/lshape_contentDA-VAST-UNSUPPORTED-FORMAT; IMA SDK unavailable → DA-VAST-IMA-SDK-MISSING; IMA load/play failure → DA-VAST-IMA-ERROR (all also raise aderror and recover). New renderer diagnose seam (AdRenderer.setDiagnoseHandler, wired from DolbyAds) so renderer-side DA-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 shared packages/demo/src/player-factory.js reused by both the Player and Pre-roll pages; per-format pre-roll manifests are produced by a new buildPreRollManifest(format, delaySeconds) helper (+ AD_EXPERIENCES) in presets.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 the adbreakbegin/adbegin/adbreakend flow. The sample manifests are now shared between the Manifests and Pre-roll pages via packages/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 explicit resumeOffset); insertion (DAI) resumes content at the exact pre-break cue. A break's manifest resumeOffset (now implemented) 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, surfaced via the new DA-INSERTION-TYPE-IGNORED-SSAI diagnostic). New portable resolveResumePoint brain unit mirrored across all three cores (TS/Kotlin/Swift) and locked by the resume-point-dar-dai conformance fixture. New diagnostics DA-INSERTION-TYPE-RESOLVED and DA-INSERTION-TYPE-IGNORED-SSAI. New AdInsertionType type exported from @dolby-ads/core. Demo gains a DAR/DAI selector (?adInsertionType= E2E param). WEB e2e adds single-dai / single-dar scenarios (PLAYG-52, minimal tier) asserting the post-break content resume position (expect.resumePosition) — DAI resumes at the cue, DAR (with the window-skip resumeOffset) 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). vast assets in linear breaks (single, double, lshape_ad) are now fetched, parsed, and rendered by the Google IMA client-side SDK (ima3.js) on web. New VastAdManager (@dolby-ads/core, exported alongside isImaCsaiSdkAvailable) drives the IMA CSAI surface (AdDisplayContainer/AdsLoader/AdsManager) — separate from the DAI-only GamStreamManager — and AdPlayerController routes VAST assets to it, emitting the normal adbegin/quartile/adend event stream and resuming content after the break. Guards surface structured diagnostics: VAST in a non-linear overlay/lshape_content break → DA-VAST-UNSUPPORTED-FORMAT; missing ima3.jsDA-VAST-IMA-SDK-MISSING; IMA load/play failure → DA-VAST-IMA-ERROR (all also raise aderror and recover). VAST remains SGAI-only — SSAI never reads the break manifest, so DA-VAST-SSAI-UNSUPPORTED is 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/core now exports a VastAsset interface (type: 'vast', uri: string | AssetUri[]) in the Asset union, plus runtime type guards isStaticAsset / isVastAsset / isVendorAsset. The Kotlin/Swift cores already carry Asset.type/uri through ManifestService unchanged; new parse tests pin that a vast asset 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-derived start value. An optional delay field (seconds, default 0) controls how long after playback begins before the pre-roll fires. Not supported in SSAI mode. New BreakPosition type exported from @dolby-ads/core. Conformance fixtures added (pre-roll-immediate, pre-roll-delayed).

Fixed

  • WEB E2E chromium content no longer stalls at currentTime 0 locally — uses system Chrome + mock-served content (PLAYG-38). Local Playwright runs (e.g. chromium, single-replace) stalled: the content <video> never advanced past currentTime 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 codecsMediaSource.isTypeSupported('video/mp4;codecs="avc1…,mp4a.40.2"') is false — so hls.js failed addSourceBuffer with bufferAddCodecError and 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 existing ad.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 local chromium Playwright project now uses channel: 'chrome' (system Google Chrome, which has H.264/AAC; install once via npx playwright install chrome); and (2) a short HLS (fMP4/CMAF) rendition of the bundled content.mp4 is 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 (contentVodUri in e2e/mock-server/media.ts, mirroring adVideoUri); E2E_CONTENT_HLS still overrides. Added a mock-server regression test (default web VOD content resolves to the mock-local origin, no external host) and documented the channel: 'chrome' requirement + ffmpeg regeneration command in e2e/README.md. Verified: chromium minimal-tier scenarios (incl. single-replace) now play content past currentTime 0 and 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> in HlsJsAdapter whenever Hls.isSupported() (every MSE browser: Chrome, Edge, Firefox), so a STATIC progressive creative (MP4) was fed to hls.loadSource() and failed fatally with manifestParsingError — the ad never played. The default factory now returns a new RoutingAdAdapter that picks the playback technology from the creative at load()/preload() time: progressive files (.mp4/.m4v/.webm/.ogv/.ogg/.mov, or a video/* Content-Type) play via NativeVideoAdapter even on MSE browsers, while HLS playlists (.m3u8/*mpegurl) use HlsJsAdapter. Detection is a synchronous URL-extension fast path with a Content-Type HEAD probe 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. New RoutingAdAdapter and selectAdTech exported from @dolby-ads/sdk. Core PlayerAdapter interface unchanged; web SDK only (no brain/conformance change).
  • tune-in WEB E2E scenario now fires adbreakbegin on slow-loading real browsers (PLAYG-29). On BrowserStack the tune-in scenario timed out (content played, session started, but no adbreakbegin). 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's EXT-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 needs timeDiff > 5s and remaining ≥ 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 to start: -10s, duration: 60s so the tune-in branch is always taken with ample remaining (tolerant of first-poll latency up to ~45s), and a new BreakSpec.oncePerSession flag 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 + oncePerSession delivery) and core BreakScheduler wallclock tune-in tests. Updated e2e/README.md and docs/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.yml pinned the iPhone leg to iOS 17 (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=native URL param) that reuses the SDK's existing NativeVideoAdapter (assigns the .m3u8 to video.src), auto-selected when Hls.isSupported() is false. Selection now flows through a pure, unit-tested resolveContentPlayerLib helper; native was 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 in docs/browserstack-web-e2e.md and packages/demo/docs/03-adapters.md. Harness/demo only — no SDK runtime code changed.
  • BrowserStack Firefox e2e was a browserName mis-config, not an autoplay block (PLAYG-35). The Firefox legs in e2e/browserstack.yml used browserName: 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 to browserName: playwright-firefox (both Windows 11 + macOS legs). With the fix, Firefox launches and muted autoplay works: the window.__dolbyE2E hook + DA-SESSION-STARTED pass and content plays to the break (verified live on the macOS Sequoia leg). Stock Firefox 150 plays the full flow fine. The remaining single-replace failure 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/AppleVTDecoder locally; a toBeVisible deserialization error on the BrowserStack driver) — so that one scenario is now test.skip-ped on firefox with an accurate rationale. Added a local firefox Playwright project as a regression guard for session/autoplay. Corrected the root-cause write-up in docs/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-id CLI flag or ORG_ID env 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 the e2e:web:browserstack / :smoke / :serial scripts. Single-session correctness: the SDK fans the platforms: matrix into concurrent workers and ignores workers: 1, so on a single-parallel-session plan the surplus workers fail in ~15 ms with ECONNREFUSED on the SDK session socket; the new scripts/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 gitignored e2e/.env only (via dotenv-cli); @playwright/test/playwright are pinned to the version the BrowserStack SDK supports. A new /run-e2e-browserstack workflow 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 in docs/browserstack-web-e2e.md.
  • Portable E2E assertion surface + rich diagnostics + mobile autoplay gesture (PLAYG-19). Under ?e2e=1 the demo installs window.__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 #eventLog fallback. 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-aderror guard.
  • GAM + SSAI WEB E2E workflows (PLAYG-22). GAM is not opt-in — the GAM workflow runs whenever E2E_GAM_NETWORK_CODE is set (real Google DAI), and is skipped with an explicit message otherwise. E2E_SSAI=1 additionally starts + tunnels a single-channel @dolby-ads/stitcher and runs the demo in mode=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 overlay break 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 manifest position/size/opacity and 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 optional duration (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 dispatches aderror and ignores that asset (no adbegin) while remaining assets play and content keeps running — matching the conformance-locked order in sdk-overlay-aderror. The native core models/parsers (Kotlin BreakManifest/ManifestService, Swift BreakManifest/ManifestService) gained the overlay layout fields (position/size/opacity) and Asset.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, and CONTRIBUTING.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>.md on the task branch. Updated AGENTS.md (source of truth), docs/jira-workflow.md, .windsurf/workflows/develop.md, .windsurf/workflows/run-e2e-browserstack.md, .devin/playbooks/develop.md, and CONTRIBUTING.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 EPIC Idea is created and moved straight to In Progress at plan start and to In Review when the plan is ready (Done on approval). Planning stays on main — 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 are KEY 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-ff merge to main: 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. Updated AGENTS.md (source of truth), docs/jira-workflow.md, .windsurf/workflows/develop.md, .windsurf/workflows/cut-version.md, .devin/playbooks/develop.md, .devin/README.md, and CONTRIBUTING.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 Idea ticket (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 the Story children are created and implementation begins. The Idea issue 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-element insertion no longer stalls content off-iOS (PLAYG-30). shared-element plays 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) and video.currentSrc is a non-reloadable blob: URL — the post-ad restore failed and content silently stalled at currentTime 0. resolveInsertionMode now guards the mis-configuration: on a non-iPhone/iPod UA a configured shared-element falls back to the default overlay insertion, logs a console.warn, and emits the new structured diagnostic DA-SHARED-ELEMENT-UNSUPPORTED (category break, level warn, added to the taxonomy and regenerated across the TS/Kotlin/Swift catalogs + ai/reference/error-codes.md). The single-fullscreen E2E scenario is marked requiresSharedElement and expected-skipped on non-WebKit browsers (it only exercises real shared-element on iOS native HLS). iPhone/iPod and the auto/adaptive/overlay paths 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.version static accessor on @dolby-ads/core (inherited by @dolby-ads/sdk).
    • Android: DolbyAds.version companion accessor on dolbyads-sdk.
    • iOS: DolbyAds.version static property on DolbyAdsSDK.
    • Stitcher: GET /version endpoint returning { "version": "x.y.z" }; the version is also re-exported from @dolby-ads/core/server and @dolby-ads/stitcher.

[0.16.0] - 2026-06-15

Changed

  • Development workflow: optional Jira mode + relaxed git push policy (PR deferred). The branch-first /develop flow now runs in three modes — IDE-only (default, zero Jira calls), EPIC (feature with one Story per 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 to main), with Done gated on explicit merge approval. The repo is no longer strictly local-only: the agent may git push the task branch and main (after merge and after /cut-version); there is still no PR, no tags, and no publish. /deploy still never pushes git. Updated AGENTS.md (now the agent-neutral source of truth), .windsurf/workflows/{develop,cut-version,deploy}.md, added docs/jira-workflow.md, a root CONTRIBUTING.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 new DolbyAds mode that plays a single pre-stitched stream from the @dolby-ads/stitcher service on the content player instead of client-scheduling breaks. A new internal SsaiController creates the IMA DAI stream (for a stream_id), builds the stitcher master URL, loads it into the content PlayerAdapter, 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, autoplay on DolbyAdsConfig. Requires a content adapter exposing videoElement (HLS.js/Shaka/native-video) plus gam.networkCode and a customAssetKey.
  • Portable buildStitcherMasterUrl URL 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 avoiding encodeURIComponent/URLSearchParams quirks) so TS, Kotlin (buildStitcherMasterUrl in dolbyads-core), and Swift (DolbyAdsCore) produce byte-identical output. Locked by new stitcher-master-url-v1 / stitcher-master-url-invalid conformance fixtures — npm run conformance 78/78.
  • Stitcher max_bitrate rendition filtering (replaces device_type). The SSAI stitcher master endpoint accepts an optional max_bitrate (bits/sec) query param and drops #EXT-X-STREAM-INF/#EXT-X-I-FRAME-STREAM-INF variants whose BANDWIDTH exceeds it (keeping #EXT-X-MEDIA; fail-open keeps the single lowest variant if none qualify). The previous client-driven device_type param 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's targeting.deviceType type is unchanged.
  • timedmetadata capability on the PlayerAdapter contract (TS/Kotlin/Swift) plus a player-agnostic TimedMetadataCue type. Content adapters (HLS.js, Shaka, THEOplayer, native <video>, ExoPlayer, AVPlayer) emit it for each in-stream marker (ID3 / EXT-X-DATERANGE / DASH emsg); GamStreamManager.processTimedMetadata forwards markers to IMA DAI for SSAI ad tracking.
  • New diagnostic codes DA-SSAI-SESSION-FAILED and DA-SSAI-IMA-ERROR in 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/stitcher CLI configured from the form fields (POST /api/stitcher/start|stop). Adds tsx + @dolby-ads/stitcher demo dev-deps and a sample channels.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). absolutizeSegments now rewrites the URI="…" 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) in conformance/PENDING-PARITY.md; the pure buildStitcherMasterUrl and the timedmetadata seam already exist in all three cores.

[0.14.0] - 2026-06-13

Added

  • SSAI stitcher now stitches double and lshape_ad breaks as a fullscreen single ad (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 new stitchCompositedAsSingle setting (server-wide default on StitcherConfig, optional per-channel override on ChannelConfig, also readable from the STITCHER_CONFIG JSON), defaulting to true; set it to false to stitch only genuine single breaks. lshape_content (backdrop image, no ad video) and overlay (content keeps playing) are never stitched. Exposes DEFAULT_STITCH_COMPOSITED_AS_SINGLE. Covered by new selectVariant unit tests, on/off pipeline tests, a double-degraded-single golden 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 bakes single-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/server subset entry (the manifest parser parseBreakManifest + 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 (same buildGamPodUrl) and splices it in, so the server holds no per-session state. Exposes GET /ssai/v1/{orgId}/{channelId}/master.m3u8 (variant URIs rewritten to the media endpoint) and GET …/media/{variantId}.m3u8 (content media playlist with breaks spliced, wrapped in EXT-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, default nearest); non-single formats 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 in packages/stitcher/CONTRACT.md; design in docs/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/server subset 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

  • ManifestService now delegates manifest validation to the shared pure parseBreakManifest (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 at effectiveDuration + margin at 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 to 0 to cut exactly at the boundary. Threaded end-to-end through the web AdPlayerController, the Android OverlayAdRenderer, and the iOS OverlayAdRenderer (the iOS renderer previously had no break-cut timer and could run past the break). Added to DolbyAdsConfig in TS/Kotlin/Swift with matching defaults.
  • The native Android E2E harness is now runnable. android/dolbyads-demo gains an androidTest instrumentation suite (ScenarioInstrumentedTest) that loads the shared e2e/scenarios/*.json fixtures (bundled as test assets), activates each scenario on the mock backend (10.0.2.2:4500), launches MainActivity via 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=1 opt-in). Extended (6) and full (8, GAM skipped) tiers pass green.
  • The native iOS E2E harness is now runnable. A new DolbyAdsE2E XCUITest target + shared scheme in ios/DolbyAdsDemo/DolbyAdsDemo.xcodeproj (sources in ios/DolbyAdsDemo/DolbyAdsE2E/, scenarios bundled as a folder reference) drives the iOS SDK (AVPlayer) through the demo. DolbyAdsDemo gains a ProcessInfo-launch-environment hook (bootIfRequested(), the iOS counterpart of the web query params / Android Intent extras) and exposes the event log as a single e2e-event-log accessibility 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_TIER default extended). Extended tier (6 scenarios) passes green on a simulator.

Changed

  • Native manifest sources now honor the manifest's polling cadence (cross-core parity fix). The Android HttpManifestSource and iOS URLSessionManifestSource previously ignored the manifest's polling block and always polled on a fixed default (30s idle), so a late-added break could be observed too late and skipped — diverging from the web ManifestService, which honors manifest.polling. polling ({ idle, active } seconds) is now parsed into the portable BreakManifest brain 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 conformance 72/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 with E2E_AD_HLS.
  • E2E wallclock/tune-in scenarios now run out of the box. The web driver defaults E2E_LIVE_CONTENT_HLS to the demo's live Content URL (which exposes EXT-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 at GET /:orgId/channels/:channelId computed 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 (minimalextendedfull) 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-e2e workflow (mapped to E2E_TIER / E2E_ADAPTERS / E2E_GAM env, no CLI flags). The legacy packages/e2e workspace was retired; root scripts e2e:web[:headed|:ui], e2e:mock, and test:e2e* now point at e2e/.
  • E2E launch hook in the web demo. packages/demo now 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 tuneIn config option ({ enabled?: boolean; minBreakDurationSeconds?: number }, default { enabled: true, minBreakDurationSeconds: 5 }) gates this: an in-progress break is only triggered when at least minBreakDurationSeconds remain, 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. The adbreakbegin event gains an optional tuneIn: { elapsedSec, remainingSec } payload (web object / Kotlin TuneInInfo / Swift TuneInInfo), and exportDiagnostics() reports the resolved tuneIn config. 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 conformance 69/69 across all cores.

[0.9.0] - 2026-06-12

Added

  • Android TV demo (android/dolbyads-tv-demo). A new com.android.application module proves the unchanged :dolbyads-runtime + :dolbyads-sdk run on Android TV: it reuses the exact phone-demo wiring (content ExoPlayer via ExoPlayerAdapterDolbyAds orchestrator with OverlayAdRenderer/HttpManifestSource/CoroutineSchedulerTicker + live event log) behind a TV-specific shell — a LEANBACK_LAUNCHER entry, uses-feature leanback/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:assembleDebugBUILD SUCCESSFUL; live ad playback (Media3/IMA) needs a device/emulator. Internal/native tooling — no change to any shipping web package.
  • Cross-language PlayerAdapter conformance kits (Kotlin + Swift). Ported @dolby-ads/adapter-test-kit to the native cores so ExoPlayerAdapter and AVPlayerAdapter are held to the same contract as the web adapters. New android/adapter-test-kit exposes an abstract JUnit base PlayerAdapterConformanceTest (override createAdapter()); new ios/adapter-test-kit exposes an open XCTestCase PlayerAdapterConformanceTestCase (override makeAdapter()). ExoPlayerAdapterTest and AVPlayerAdapterTests now 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 fires volumechange via AVPlayer KVO; ExoPlayer events need real playback, so Android delivery is skipped via Assume and covered by on-device integration). The playeradapter.contract.md reference now documents the native kits. Internal/native tooling + test — no change to any shipping web package's public API.
  • Verified @dolby-ads/mcp consumes native diagnostic reports + native JSON export. The native SDKs gained a JSON serializer for exportDiagnostics() — Kotlin DiagnosticReport.toJson() (DiagnosticsJson.kt) and Swift DiagnosticReport.toJSONString() (DiagnosticsJson.swift) — that emits the same report schema as the web SDK (nested config.chaining, lowercase level/category). Genuine reports captured from a real Kotlin and Swift session (the new DiagnosticsExportTest/DiagnosticsExportTests, run with WRITE_FIXTURE=1, timestamps normalized) are committed as packages/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.md and ios/ai/README.md mapping the shared packages/sdk/ai/* reference docs to each native platform (the DA-* taxonomy and PlayerAdapter contract are identical across cores), since native SDKs are not distributed via npm and have no dolby-ads-init-ai equivalent. Cross-linked from android/README.md, ios/README.md, and ai/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) plus SDK_VERSION stamped 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/mcp snapshot and ai/reference/error-codes.md continue to derive from the built core (now itself generated), so they stay single-sourced. Fixes a latent bug: the diagnostic SDK_VERSION was stale (0.4.0 on web, 0.7.0 on native) and is now correctly stamped (0.8.4). No codes changed; behavior is unchanged. The /cut-version and /run-tests workflows 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 *-web subsection alongside the existing *-android / *-ios ones, while genuinely shared references (the Events table and the Diagnostic codes table) stay visible on every platform. The selected platform persists via localStorage and is inferred from -android / -ios deep-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 SDKAndroid SDK (Kotlin)iOS / tvOS SDK (Swift)How-To (all SDKs)Concepts & IntegrationsTooling → 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/GamConfig tables, startSession/endSession/updateAdTagParameters/play/pause/seek signatures, addEventListener idioms + event payloads, onDiagnostic/exportDiagnostics + DiagnosticEvent shapes (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 full PlayerAdapter interface, 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-platform android/README.md + ios/README.md; a "Repository guide — where to find what" section in README.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-version workflows and AGENTS.md no longer hedge native as "when the packages exist", and /sync-docs gained native-doc mapping rows. Shipped integrator AI artifacts gained a concise, clearly-scoped ai/reference/native-overview.md (auto-included by dolby-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 renamed dolbyads-android/com.dolby.ads.androiddolbyads-runtime/com.dolby.ads.runtime for 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-runtime and the Kotlin package com.dolby.ads.androidcom.dolby.ads.runtime, so the Android layer packages are now a consistent sibling set (com.dolby.ads.core/.sdk/.runtime) mirroring iOS DolbyAdsCore/SDK/Runtime. Updated settings.gradle.kts, the module/demo build.gradle.kts (namespace + project(":dolbyads-runtime") dependency), all package/import statements (3 runtime sources + the test + the demo's MainActivity), and every doc reference. The Gradle build's root project name stays dolby-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. A DemoController (ObservableObject) wires a content AVPlayer (via AVPlayerAdapter) into a per-session DolbyAds instance built from the default Foundation seams (URLSessionManifestSource + DispatchSchedulerTicker) and the OverlayAdRenderer, rendering content + the ad overlay in a single AVPlayerLayer-backed UIView. The UI exposes org/channel/content-URL/GAM-network/custom-asset-key inputs, Start/End/Diagnostics controls, and a live log that subscribes to every DolbyAdsEventType + the diagnostic stream and can dump a redacted exportDiagnostics() report. To keep integration to a single import, DolbyAdsRuntime now @_exported imports DolbyAdsCore + DolbyAdsSDK, so the app wires everything with just import DolbyAdsRuntime. Builds via a committed .xcodeproj referencing the local Swift package: xcodebuild -scheme DolbyAdsDemo -destination 'id=<iPhone 16 sim>' buildBUILD SUCCEEDED. Internal/native tooling — no change to any shipping web package.
  • iOS runtime unit tests (ios/DolbyAdsRuntime, P5b-3) — the Swift mirror of Android's ExoPlayerAdapterTest. A new DolbyAdsRuntimeTests XCTest target adds AVPlayerAdapterTests, which runs on the iOS simulator (real AVPlayer, no device/IMA needed) and verifies the AVPlayerAdapter's portable PlayerAdapter mapping: volume pass-through + clamping to [0, 1], mute pass-through, unset-duration → +Infinity, the initial paused/zero/no-PDT state, and volumechange event subscribe/unsubscribe via the token-based on/off. Verified with xcodebuild 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 Android P4b-2b. A new GamStreamManager wraps the Google IMA SDK for iOS (GoogleInteractiveMediaAds 3.32.0, added via Swift Package Manager): it requests a DAI IMAPodStreamRequest, exposes the resulting streamId, forwards ad-break quartile/error events, and supports replaceAdTagParameters. Unlike Android, the ad AVPlayer is wrapped in an IMAAVPlayerVideoDisplay, so IMA reads the pod's ID3 timed metadata automatically — no manual metadata forwarding is needed. OverlayAdRenderer now accepts an optional GamConfig: when supplied, isGamEnabled() is true, startGamSession initializes the IMA session per monetization session, and GAM vendor assets resolve to freshly built pod-manifest URLs via the portable, conformance-locked buildGamPodUrl + 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 with xcodebuild -scheme DolbyAdsRuntime -destination 'generic/platform=iOS Simulator' buildBUILD 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 of android/dolbyads-runtime. AVPlayerAdapter implements the portable PlayerAdapter over an integrator-owned AVPlayer (current time/duration/paused/muted/volume, programDateTime from AVPlayerItem.currentDate() for HLS PDT matching, pause/play/seek/load, and KVO + a periodic time observer + item notifications fanned out to the SDK's PlayerAdapterEvents with token-based on/off). OverlayAdRenderer implements the AdRenderer seam — playing break assets on a dedicated ad AVPlayer in an AVPlayerLayer overlaid above the integrator's content surface (content paused), with the public ad-event order driven entirely by the conformance-verified core AdBreakSequencer (overlay single/multi-asset, the lshape_content backdrop, 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 via xcodebuild against an iOS destination rather than swift build on macOS — verified with xcodebuild -scheme DolbyAdsRuntime -destination 'generic/platform=iOS Simulator' buildBUILD 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 Android dolbyads-sdk: a DolbyAds orchestrator that wires the portable DolbyAdsCore brain to a PlayerAdapter and emits the public SDK event stream (adbreakbeginadbegin/adend/aderroradbreakend, GAM quartiles, waiting/playing), with the same session lifecycle, overlapping-break suppression, snapback/seek gating, ad-tag-parameter merge, and redacted exportDiagnostics() report as the TS/Kotlin references. Platform work is injected behind AdRenderer, ManifestSource, and SchedulerTicker protocols; two default Foundation seams are included — URLSessionManifestSource (URLSession fetch + DispatchSourceTimer poll, validated through the conformance-locked core ManifestService) and DispatchSchedulerTicker (drives the timer-less core BreakScheduler.tick()). Async is Swift concurrency (startSession/playBreak/preload). To support the orchestrator, the Swift core was brought to full parity with Kotlin: PlayerAdapter expanded to the complete contract (duration/paused/muted/volume/programDateTime/pause/play/seek/load/events/destroy, token-based on/off), the Break model gained BreakFormat/Asset/BreakVariant/Controls + resumeOffset/controls/variants (lenient parse), and BreakScheduler gained stop(). swift test (6 tests: orchestrator wiring + seams) and npm 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 new com.android.application module mirrors the web demo: a single MainActivity wires a content ExoPlayer (via ExoPlayerAdapter) into the DolbyAds orchestrator with the OverlayAdRenderer, HttpManifestSource, and CoroutineSchedulerTicker, and surfaces a live SDK event log (subscribing to every DolbyAdsEventType + the diagnostic stream) plus a redacted exportDiagnostics() dump, with org/channel/content-URL inputs and start/end/diagnostics controls. ./gradlew :dolbyads-demo:assembleDebug produces a runnable debug APK. The Android runtime now also has a Robolectric unit-test harness: ExoPlayerAdapterTest verifies 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/isGamVendorParameters were ported to the Kotlin (android/dolbyads-core) and Swift (ios/DolbyAdsCore) cores (mirroring the web GamPodUrlBuilder), and three new conformance fixtures (gam-pod-url-v1/-v2/-invalid) pin the EABN V1 /pod/ vs V2 /ad_break_id/ paths, the floored pd (ms), and rejection of malformed vendor params byte-for-byte across TS/Kotlin/Swift (npm run conformance63/63). On Android, a new GamStreamManager (android/dolbyads-runtime) wraps the Google IMA SDK (com.google.ads.interactivemedia.v3:interactivemedia:3.35.1): it requests a DAI PodStreamRequest, exposes the resulting streamId, forwards ad-break/quartile/error events, supports replaceAdTagParameters, and relays HLS ID3 timed metadata to IMA. OverlayAdRenderer is now GAM-aware — a GamConfig enables isGamEnabled(), startGamSession() initializes the IMA session, and vendor: "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 portable Asset gained optional vendor/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: ExoPlayerAdapter implements the portable PlayerAdapter over Media3/ExoPlayer (position/duration/paused/muted/volume, programDateTime from the live window's wallclock anchor for HLS PDT matching, pause/play/seek/load, and a single Player.Listener fanned out to the SDK's PlayerAdapterEvents), and OverlayAdRenderer implements the AdRenderer seam — playing break assets on a dedicated ad ExoPlayer in a PlayerView overlaid above the integrator's content surface (content paused), with the public ad-event order driven entirely by the conformance-verified core AdBreakSequencer (overlay single/multi-asset, the lshape_content backdrop, the asset-error path, and consecutive-break chaining). All ExoPlayer interaction is marshalled to Dispatchers.Main. Scope is static (direct-URL) insertion; GAM/IMA DAI pod serving is the next sub-phase (isGamEnabled() is currently false). The portable Asset gained optional uri/mediaType (parsed leniently, conformance-neutral) so the renderer can load static assets. Toolchain: a new com.android.library module 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 via gradle.properties), with google() repos and a gitignored local.properties. ./gradlew build (all three modules + Android lint) and npm 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: CoroutineSchedulerTicker drives the timer-less core BreakScheduler.tick() from a coroutine delay loop (default 250 ms, mirroring the web setInterval), and HttpManifestSource fetches the manifest over HTTP and validates it through the conformance-locked core ManifestService. The fetch is injected via a ManifestFetcher fun-interface (default OkHttpManifestFetcher, blocking call on Dispatchers.IO) so it unit-tests without networking; JSON is decoded with kotlinx-serialization-json into the plain Any?/Map/List tree the parser expects (insertion order preserved). Polling matches the web reference (idle 30 s / active 5 s, interval chosen at startPolling, setActive records 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 (Media3 PlayerAdapter, overlay AdRenderer, 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 web DolbyAds orchestrator (pure Kotlin/JVM, no Media3/IMA/networking). It wires the portable :dolbyads-core brain to a PlayerAdapter and emits the public SDK event stream (adbreakbeginadbegin/adend/aderroradbreakend, plus GAM quartiles and waiting/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 web AdPlayerController's role), ManifestSource (network fetch + poll; the parse/validate half stays in the conformance-locked core ManifestService), and SchedulerTicker (drives the timer-less core BreakScheduler.tick(), mirroring the web 250 ms interval). The portable Break model gained optional variant/controls/resumeOffset (parsed leniently, forward-compatible) and the core BreakScheduler gained a stop() to match the TS lifecycle. Async is coroutine-based (startSession/playBreak/preload suspend). 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 real ManifestSource/SchedulerTicker land in P4b. Internal/native tooling — no change to any shipping web package.
  • Portable AdBreakSequencer in @dolby-ads/core (P4a-2) — the single, DOM-free source of truth for the order of the SDK's public ad events: adbreakbegin → per asset adbegin/adend (or aderror) → adbreakend, including consecutive-break chaining (overlay only), the shared-element single-fullscreen downgrade, and the lshape_content backdrop (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. Exposes AdBreakSequencer, selectVariant, extractAssets, and the EffectiveInsertionMode/AdBreakStep/AdBreakAction/AdBreakOutcome/AdBreakTransition/ChainResolver types.
  • 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 AdBreakSequencer and emits a normalized sdkSequence, and the AdBreakSequencer is mirrored in the Kotlin (android/dolbyads-core) and Swift (ios/DolbyAdsCore) cores. Seven new fixtures cover overlay single/multi-asset, the asset-error path (aderroradend, no adbegin), the lshape_content backdrop, overlay chaining, and the shared-element single-fullscreen downgrade + lshape_content skip. All three cores pass every fixture (npm run conformance54/54). Internal/native tooling — no change to any shipping web package.
  • Web AdPlayerController now delegates to the shared AdBreakSequencer (P4a-2b). Variant/asset selection (getVariant/getAssets) and the intra-break ad-event ordering (the adbegin/adend/aderror sequence 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, the lshape_content backdrop, shared-element, and all DOM/IMA remain owned by their existing paths. Behavior is unchanged — all AdPlayerController suites (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 (root dolby-ads-android, module :dolbyads-core), a root build script, and dolbyads-core/build.gradle.kts (Kotlin/JVM library, JVM target 17). ./gradlew :dolbyads-core:build compiles 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 same src/main/kotlin sources via kotlinc (npm run conformance → 33/33 still green). Gradle/.gradle/build artifacts 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): BreakManifest types, ManifestService validation (error messages identical to TS/Kotlin), BreakScheduler (scheduling, PTS/wallclock timebase via an injectable Clock + 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 with swift build (Xcode/SwiftPM); insertion order preserved via an ordered array (JS Map parity) and jsRound/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): BreakManifest types, ManifestService validation (error messages identical to TS), BreakScheduler (scheduling, PTS/wallclock timebase via an injectable Clock + 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-core pathPrepend/env (the Kotlin core targets a Homebrew kotlinc + 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: a dolby-onboarding agent (ai/agents/dolby-onboarding/AGENT.md) that tutors a newcomer 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 offers to try it; and a dolby-quickstart-demo skill (ai/skills/dolby-quickstart-demo/SKILL.md) that collects a few inputs and bootstraps a runnable demo. Both ship via dolby-ads-init-ai. A new scaffold_quickstart tool in @dolby-ads/mcp (also exported as the pure scaffoldQuickstart/normalizeQuickstartInput functions) turns { orgId, channelId, player, contentUrl, environment?, gam? } into a self-contained single-file index.html demo (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 runs scaffold_quickstart fully client-side.
  • Scheduler determinism seam (groundwork for the cross-language conformance harness). BreakScheduler now takes an optional injectable Clock (default systemClock, backed by Date.now()) used for the wallclock timebase, and exposes a public tick() entry that the internal poll interval delegates to — so a driver can step time deterministically without real timers. Clock and systemClock are 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 the Clock/tick() seam) and emits a normalized result, pinned as a committed golden (expected.json). The orchestrator (conformance/run.mjs, scripts npm 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 a core.json per conformance/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 adaptive ad-insertion mode. On iPhone/iPod, auto now resolves to adaptive when ManagedMediaSource is available (iOS 17.1+), so the ad engine runs through HLS.js/MMS instead of the degraded native fallback. In adaptive mode 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 to shared-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 to shared-element; every other platform stays on overlay. adInsertion: 'adaptive' can also be set explicitly.
  • adaptive added to the AdInsertionMode / ResolvedInsertionMode types; new detectMediaSourceCapabilities() helper and MediaSourceCapabilities type exported from @dolby-ads/core. resolveInsertionMode() now takes a MediaSourceCapabilities object (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 Assistant docs 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, the dolby-ads-init-ai CLI, registering the @dolby-ads/mcp server, and the troubleshooting flow. Cross-referenced from the Overview and the root README.md.
  • Demo AI Troubleshooting panel — the demo app now has an "AI Troubleshooting" card with three actions: AI Analyze (runs the same analyzeDiagnostics logic as @dolby-ads/mcp, fully client-side, and renders the root-cause hint, ranked findings with remediation, and timeline observations), Export Report (shows the redacted exportDiagnostics() JSON with copy/download), and Check Adapter (inspects the live PlayerAdapter for required/optional contract members). Structured diagnostics are also surfaced live in the event log.
  • dolby-ads-init-ai CLI in @dolby-ads/sdk — scaffolds the SDK's AI artifacts (skill, agent, and references) from the package's ai/ folder into a consumer's project so their AI IDE (Claude Code, Windsurf, Copilot + AGENTS.md, etc.) can pick them up. Run npx dolby-ads-init-ai [targetDir] [--force] (defaults the target to ./.dolby-ads/ai); existing files are skipped unless --force is passed. The copy logic is also exported as pure functions runInitAi(options) / listFilesRecursive(dir) (with InitAiOptions / InitAiResult types) from @dolby-ads/sdk.
  • @dolby-ads/mcp — a Model Context Protocol (stdio) server exposing Dolby Ads troubleshooting tools to AI assistants via the dolby-ads-mcp binary. Three tools: lookup_error_code (code → category/level/summary/remediation), analyze_diagnostics (severity tallies + ranked findings with remediation + timeline + root-cause hint from an exportDiagnostics() report), and explain_event_timeline (DA-EVENT sequence + ad-break lifecycle anomalies). Implements initialize/tools/list/tools/call/ping over newline-delimited JSON-RPC 2.0. The server is self-contained — it embeds a generated, drift-guarded snapshot of the @dolby-ads/core diagnostic 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/sdk under ai/: a dolby-adapter-integration skill (ai/skills/.../SKILL.md) for scaffolding/validating a PlayerAdapter, a dolby-troubleshooter agent (ai/agents/.../AGENT.md) for diagnosing from exportDiagnostics() reports, and machine-readable references: playeradapter.contract.md, knowledge-base.md (code → cause → fix), and a generated error-codes.md. error-codes.md is generated from the @dolby-ads/core taxonomy via npm run gen:ai-docs -w @dolby-ads/sdk, and a drift-guard test fails the build if it falls out of sync. The ai/ folder is published with the package.
  • @dolby-ads/adapter-test-kit — a shared PlayerAdapter conformance suite. runAdapterConformance(name, { createAdapter, capabilities, emit? }) registers a standard set of checks (all seven events forward, off()/destroy() cleanup, currentTime/duration/paused/muted/volume/programDateTime accessors, seek(), and optional videoElement/preload/supportsParallelBuffering capabilities) so custom adapters can prove they satisfy the contract the SDK core relies on. The official @dolby-ads/adapter-hlsjs and @dolby-ads/adapter-shaka adapters 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) deliver DiagnosticEvents with a stable code, category, severity level, and JSON context. sdk.exportDiagnostics() returns a self-contained, redacted DiagnosticReport (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. New diagnostics?: { bufferSize?: number } config (default 200) bounds the in-memory ring buffer. A stable code taxonomy (DIAGNOSTIC_CODES) and the DiagnosticEvent / DiagnosticReport / DiagnosticBuffer / DiagnosticsConfig / KnownDiagnosticCode types plus SDK_VERSION are exported from @dolby-ads/core. Emitted SDK events are mirrored into the timeline under the DA-EVENT code.
  • Consecutive-break chaining via the new chaining config option ({ enabled?: boolean; maxGapSeconds?: number }, default { enabled: true, maxGapSeconds: 2 }). When two breaks are separated by a gap no larger than maxGapSeconds, 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 / ResolvedChaining types, DEFAULT_CHAINING, and resolveChaining are exported from @dolby-ads/core. Not applied in shared-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 in shared-element mode.

[0.4.0] - 2026-06-09

Added

  • adInsertion config option ('overlay' \| 'shared-element' \| 'auto', default 'auto') enabling iPhone Safari support. On iPhone/iPod (auto) the SDK switches to shared-element insertion: 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 and lshape_content is 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 / isSharedElementUserAgent helpers and ResolvedInsertionMode / AdInsertionMode types exported from @dolby-ads/core.
  • NativeVideoAdapter.programDateTime now derives PDT from WebKit's HTMLVideoElement.getStartDate() + currentTime, enabling wallclock break matching on iPhone native HLS (returns null when unavailable).
  • New @dolby-ads/sdk entry package that defaults the ad player to HLS.js (with a native <video> fallback for MSE-less platforms such as Safari/iOS/older tvOS). Importing DolbyAds from @dolby-ads/sdk makes createAdAdapter optional.
  • adPreload config option ('parallel' | 'single-decoder' | 'auto', default 'parallel'). single-decoder warms 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). auto resolves the mode via User-Agent inspection.
  • Optional preload?(url) and supportsParallelBuffering? members on the PlayerAdapter interface. Implemented by adapter-hlsjs (detached prefetch via a throwaway Hls instance with startFragPrefetch) and the new NativeVideoAdapter.
  • resolvePreloadMode / isSingleDecoderUserAgent helpers exported from @dolby-ads/core.

Changed

  • createAdAdapter is now optional in DolbyAdsConfig. The bare @dolby-ads/core DolbyAds throws a clear error if it is omitted (no default); use @dolby-ads/sdk or supply your own factory.

[0.2.0] - 2026-06-09

Added

  • waiting and playing playback events on the SDK, emitted for both content and ad playback. Each carries a source: 'ad' | 'content' field; break/asset are present only when source === 'ad'. playing fires on every native playing event.
  • waiting/playing support in the PlayerAdapter interface and all adapters (adapter-hlsjs, adapter-shaka, adapter-theoplayer).

Changed

  • aderror now covers both ad and content playback errors, distinguished by a new source: 'ad' | 'content' field. break/asset are now optional (present only for ad errors). The emitted Error now includes the underlying media error code/message when available.

[0.1.0]

Added

  • Initial project setup with monorepo structure
  • @dolby-ads/core package with PlayerAdapter interface
  • @dolby-ads/adapter-hlsjs package for HLS.js integration
  • Break manifest polling and parsing
  • Static HLS ad insertion (MVP Phase 1)