Skip to main content

Advertising

The web player monetizes playback two ways, and both can apply to the same load:

  • CSAI (client-side ad insertion) — the player requests a GAM VAST tag through the Google IMA SDK and plays the pod itself, pausing content around it. This is how on-demand ad breaks work: pre-roll, mid-roll and post-roll.
  • SSAI / Google DAI (server-side ad insertion) — ads are stitched into the stream before it reaches the player, which only renders an overlay over the ad segments. This is how live / FAST channels work.

CSAI VOD breaks and live DAI never compete for the same title: isLive gates the on-demand hooks off, and the DAI config is only built for live content.

Where breaks come from

Ad breaks are read from the playback response, never from catalog metadata — entitlement decides whether a title gets ads, so only the playback service can answer it. Each break carries a type (pre | mid | post), a start offset in seconds, and its own ad tag.

The playback request body must carry platform: 'web'. The middleware picks the GAM tag template from providers.ovp.ads.tags.<platform> in Accedo Control and attaches no breaks at all when the platform is absent or unrecognized — there is no default and no legacy fallback (RECORD-4230). Every playback call is built by buildPlaybackRequestBody in src/providers/playback/serviceCore/playback.ts, which sets it, as does the split-session start request.

Catalog (OVP) responses carry no adBreaks on any route — movie, episode, show, season, search, playlist, category or podcast. A podcast episode is an episode to both the playback service and the player, so it gets pre-roll, podded mid-rolls and post-roll exactly like a movie.

The tag lives in one of two places on a break, resolved in this order by getAdTagFromBreak (src/dataModels/ads.ts):

  1. metadata — stringified JSON, e.g. {"tags":["<GAM VAST url>"]}
  2. the first ads[].additionalData that looks like a URL

getAdTagsFromBreak returns every tag by the same sourcing order. A mid-roll break carries one tag per pod slot, so reading only the first plays a single ad instead of the whole break — see Filling a mid-roll break below.

A break's own duration is always 0: how long the served ad runs is GAM's decision, so scheduling reads start only.

The tag reaches IMA verbatim. The middleware emits Accedo Control's template with its macros already populated, and ad ops traffics against exactly that URL, so nothing here may add, strip or reorder its parameters — AdManagerController assigns it straight to AdsRequest.adTagUrl. IMA appending its own device/session parameters (correlator, rdid, msid, sid, url, sdkv) at request time is expected and is not the app's doing.

PositionResolverFallback when type is missing
Pre-rollresolvePreRollAdTaga break at start === 0, then vmapUrl
Mid-rollgetMidRollCuesnone — an untyped break is not a mid-roll
Post-rollresolvePostRollAdTaga break at start === -1 (the VMAP position)

Post-roll deliberately does not fall back to vmapUrl. A VMAP playlist already encodes its own pod schedule and the pre-roll has claimed that URL, so reusing it at the end of content would replay the whole schedule.

Pre-roll

Requested as part of the initial load(), from the ad config the player component builds during render. Nothing schedules it — IMA plays it before content starts.

Mid-roll

MidRollSchedule holds the product rules, identical on every platform:

  • a cue at or before the position playback starts from counts as already played (so resuming from a bookmark does not replay the breaks behind it);
  • a forward seek across several unplayed cues plays only the last one crossed, and consumes the rest silently;
  • a cue plays at most once per session, however it was reached.

All three fall out of takeDueCue consuming every due cue and returning the last one: normal playback crosses one cue per tick, a seek crosses many.

useMidRollAds drives the schedule off the player clock. It listens to timeupdate and seeked — a forward seek lands seeked first, so a scrubbed-past break fires as soon as the seek settles rather than on the next tick. It skips ticks while an ad is on screen (ad time is not content time and would burn the rest of the schedule) and serializes requests so two breaks cannot overlap.

ShakaPlayer.requestMidRollAd plays one slot; a podded break calls it once per tag (see Filling a mid-roll break below). Pausing/resuming content is bracketed around the whole break, not each slot, by beginMidRollBreak/endMidRollBreak: the former captures the playhead position and playing state once, before the first slot; the latter restores it once, after the last one, to the position the break interrupted — after a seek across cues that is the seek target, not the cue offset — resuming content only if it was playing when the break started. Capturing or resuming per slot instead would read content as already paused by the second slot and either lose the original position or fail to resume at all (RECORD-4066). requestMidRollAd itself never rejects: a broken slot must not be able to strand content.

Filling a mid-roll break

A mid-roll break is several ads, not one. GAM answers one VAST request with one ad, so the middleware puts one tag per slot on the break — the same pod id with an incrementing ppos, which is what makes GAM treat them as one break for competitive separation and frequency capping.

How many ads is metadata.podMaxAds, Accedo Control's Midroll Max Ads Per Break. It is both the number of slot tags the middleware publishes and the ceiling, so the two can never disagree, and a break's length is simply what its ads run to.

There is a second, older ceiling, metadata.podTargetSeconds — seconds of ads to fill. It is being retired (RECORD-4230): two ceilings meant a break advertised a length it could not always reach, since 90s across 3 slots needs three 30s creatives. Deployed middleware still sends it, and the middleware that replaces it does not, so the client honours both while the rollout is in flight.

A break is podded when either signal says sopodMaxAds above one, or a positive podTargetSeconds — and it ends at whichever present ceiling binds first. Neither present means a single ad.

fillMidRollBreak (src/features/Player/ads/fillMidRollBreak.ts) plays the slots:

  • it fills to the break's slot budget: podMaxAds bounded by the tag count;
  • while a podTargetSeconds is still present it also measures how long each ad actually ran and stops once the target is met. There is no slot count to compute in advance from a duration, because only GAM knows how long each ad will be;
  • where the target is what ends the break, the final ad may carry it past the target. That is the intended direction — stopping short would leave the break visibly unfilled;
  • the count ceiling wins over the target: three 10s creatives fill 30s of a 90s break and the break still ends, because it is the limit the ads policy puts on how many times a viewer is interrupted;
  • a slot that plays nothing (no-fill or error) ends the break immediately, since the next request would almost certainly do the same and each attempt costs the viewer a stall.

No ad number is a client-side constant. A break with no podMaxAds is one the middleware is not capping by count — the client must not substitute a number of its own. A podMaxAds that is not a positive whole number is read the same way: as no cap, never as no ads. Fractional values are rejected rather than rounded, matching the middleware, because rounding 2.1 to 2 would invent a ceiling nobody configured.

The middleware emits exactly two shapes:

{ tags: [t1..tN], podMaxAds: N }   // N is 2..12, always === tags.length
{ tags: [t1] } // cap of 1, unset, fractional or non-numeric

A cap of 1 is deliberately not a third shape carrying podMaxAds: 1: a break of one ad is not a pod, and the presence of podMaxAds is what marks a break as podded.

The pod position label is ours, not IMA's. Each slot is a separate IMA request, so IMA reports every one of them as "1 of 1". The total is the break's own slot budget — podMaxAds bounded by the tag count, both from the middleware — so it is known before the first request and cannot move mid-break. It is deliberately not derived from ad lengths: an estimate recomputed per slot makes the overlay visibly wobble ("1 de 3", then "2 de 5"), and a break needing one more slot than estimated has to correct itself in front of the viewer.

Pre-roll and post-roll are never podded — one tag, no pod/ppos.

The cue offsets are also painted on the progress bar as markers (buildAdBreakMarkerGradient in AppCorePlayerControls): the controls library exposes no slot for children, so the markers are one CSS gradient laid over its track, and nothing is drawn until the duration is known.

Post-roll

useVodPostRoll fires the break on the player's ended event — not from a percentage cue and not from the binge-watch flow — so every on-demand title gets one, movies and trailers included, and only once content has genuinely finished. It is idempotent per title: a second end-of-playback signal within the same break joins the first request instead of starting another.

isPostRollPending is true while the title still owes the viewer a post-roll. It holds the end-of-playback destination: the next-episode card stays off screen, the countdown does not start, and content end does not navigate. The resulting order is content → post-roll → next-episode countdown → next episode, matching iOS and CTV.

No pre-roll straight after a post-roll

Continuing a binge does not play the next episode's pre-roll when a post-roll just ran, so the viewer never sits through two breaks back to back across the transition. preRollSuppression is a module-level marker keyed to the id of the episode being navigated to — a bare "skip the next pre-roll" flag would eat the pre-roll of whatever the viewer played next, so cancelling the countdown and picking something from a rail would silently cost that title its ad.

Only a break that actually rendered an ad marks anything (a no-fill, VAST error or timeout does not), and only the binge-continuation path does — manual navigation behaves normally.

The marker is module state rather than React state because it has to survive the player unmounting across the route change. The player reads it during render (the ad config is built there, before the first load()) and consumes it in an effect, once that render has committed: consuming during render would clear a marker for a render React discarded or replayed, and the episode would then get the pre-roll the post-roll was meant to replace.

A consequence, agreed as product behaviour: a continuous binge chain shows post-rolls only.

One ad manager per player

Shaka hands out one ad manager per player instance, and releasing it nulls its listener registry permanently. Releasing it when a break ends therefore deafens every later break — it renders with no ad UI and only ends when the watchdog fires.

So AdManagerController splits its teardown:

  • detach() — the correct teardown between breaks. Drops this controller's listeners, watchdogs and polling, resolves anyone awaiting the break, and leaves the shared ad manager alone. Nothing leaks: initClientSide releases the previous client-side manager itself.
  • destroy()detach() plus releasing the ad manager. Only correct when the player itself is going away.

Each break gets a fresh controller (the completion callback fires once, so a reused instance goes silent on the second break), and the leftover IMA iframe is made inert with CSS instead — only .adActive > * takes pointer events.

Failure behaviour

Every ad path is fail-open: a no-fill, VAST error, SDK load failure or watchdog timeout resolves the request and continues to content. Nothing an ad server does can leave the viewer staring at a stalled player.