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):
metadata— stringified JSON, e.g.{"tags":["<GAM VAST url>"]}- the first
ads[].additionalDatathat 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.
| Position | Resolver | Fallback when type is missing |
|---|---|---|
| Pre-roll | resolvePreRollAdTag | a break at start === 0, then vmapUrl |
| Mid-roll | getMidRollCues | none — an untyped break is not a mid-roll |
| Post-roll | resolvePostRollAdTag | a 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 so — podMaxAds 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:
podMaxAdsbounded by the tag count; - while a
podTargetSecondsis 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:initClientSidereleases 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.