Skip to content

feat(ads): native Google IMA client-side ad insertion (iOS + Android) - #5136

Draft
Ibad9 wants to merge 1 commit into
TheWidlarzGroup:masterfrom
Ibad9:ima-ads-integration
Draft

Ibad9 wants to merge 1 commit into
TheWidlarzGroup:masterfrom
Ibad9:ima-ads-integration

Conversation

@Ibad9

@Ibad9 Ibad9 commented Sep 24, 2026 •

Copy link
Copy Markdown

Summary

Adds client-side video ads (pre-roll, mid-roll, post-roll, VMAP, ad pods) on iOS and Android through Google's IMA SDK. Ads are opt-in per source with ads: { adTagUrl } and controlled with activateAds(), deactivateAds(), skipAd(), isPlayingAd and adState. Ad events can be followed with one listener, onAdEvent, which delivers { type, data }.

Motivation

The library had no way to play ads. IMA is the standard ad SDK on both platforms. Playback is gated natively, so no content frame renders before the ad decision is made, and every failure path falls back to content.

Changes

  • JS / Nitro: VideoAdsConfig (VideoConfig.ads), adState, isPlayingAd, activateAds / deactivateAds / skipAd, and 11 events (onAdsResolved, onAdBreakStart/End, onAdProgress, onAdStart/Complete/Skipped/Clicked, onAdError, onAllAdsCompleted, onAdStateChange). onAdEvent delivers all of them through one listener.
  • iOS: ios/core/Ads/* (IMA through $RNVideoUseGoogleIMA). The ad UI lives in AVKit's contentOverlayView. Entering or leaving fullscreen while an ad plays does not crash, an ad source swapped in while the view is on screen no longer skips the pre-roll, and fullscreen follows the phone's rotation.
  • Android: src/ima/* (IMA through RNVideo_useExoplayerIma).
  • adPodIndex means the same on both platforms: the zero-based position of the ad within its pod.
  • Example app: an Ads panel with six Google sample tags (single, skippable, VMAP pre / post / pre+mid+post / pod), fullscreen buttons, and an event log driven by one onAdEvent listener.
  • Docs: docs/docs/player/ads.md (setup, integration checklist, sample tags, config, activation, state, events, limitations, troubleshooting). Sidebar positions of Downloading and Analytics shift by one.
  • Unit test: __tests__/adEvents.test.ts.

Platforms affected

  • Android
  • iOS
  • visionOS
  • tvOS / Android TV
  • Windows
  • Web (type signature only, not run)

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behavior)
  • Documentation only

Test plan

Automated

  • adEvents.test.ts (unit, 13 tests): each of the 11 ad events is wired to its native listener, forwards its payload and unsubscribes; onAdEvent subscribes to all of them, reports { type, data } for each, and removes every native listener when unsubscribed. Mutation-tested: breaking one mapping fails a test.
  • bun run test (224 pass), bun lint and bun typecheck pass (run by the pre-commit hook). Lint reports 8 existing warnings in VideoView.web.tsx.
  • No Maestro flow: the ad tags are Google's public sample endpoints, so a flow would depend on the network and on a third-party server (and Google's skippable sample tag returned no fill during testing, see below). A local fixture server was tried and Android IMA rejects local http tags.

Manual, with the example app's Ads panel and Google's sample tags (release builds of this branch)

  • iOS: iPhone 17 Pro simulator, iOS 26.4.
  • Android: Pixel 6 Pro (API 34) emulator, arm64.
  • Steps for each: open the app, turn on Show Video, pick a format, watch the ad and the event log.
Case iOS Android
Pre-roll plays, then content; event log shows ad break start, ad start (1/1), ad break end, all ads completed Pass Pass
Enter fullscreen while the ad plays: no crash, ad keeps playing, content resumes after it Pass (0 crashes) Pass (ad keeps playing in landscape fullscreen, back returns to portrait)
VMAP pre-roll Pass Pass
No fill: the ad request fails, onAdError fires and content plays Pass Pass
Skippable tag and skipAd() Blocked: Google's sample tag returned no fill on every attempt Blocked: same
VMAP pre + mid + post, pod, post-roll Not re-run on this build Not re-run on this build

Media: screen recordings and screenshots of the cases above on both platforms.
android-1-preroll-playing
android-3-fullscreen-during-ad
android-7-vmap-preroll-playing

Uploading android-fullscreen-during-ad.

Uploading android-preroll-and-events.mp4…

mp4…

ios-preroll-and-events.mp4
ios-1-preroll-playing ios-3-fullscreen-during-ad ios-7-vmap-preroll-playing
ios-fullscreen-during-ad.mp4

Known limits (also in the docs)

  • Android: an ad stalls when the VideoView uses surfaceType="texture"; the default surface works. Not fixed yet.
  • Android pre-rolls take about 2 to 3 seconds to appear; iOS is faster.
  • iOS: AVKit's own controls are hidden while an ad plays, so drive fullscreen from your own UI. Fullscreen follows the phone's rotation only when the app's Info.plist allows landscape;
  • Google's public sample tags are not always filled (the skippable tag and the VMAP post-roll were unreliable during testing).
  • Android and iOS only; client-side ads only.

Checklist

  • I read the contributing guidelines.
  • I added or updated tests that cover this change, or explained under "Test plan" why none apply.
  • For a bug fix the E2E suite can exercise, this PR includes a Maestro flow that fails without the fix, or "Test plan" explains why none does. (N/A: new feature, not a bug fix)
  • bun run test, bun lint and bun typecheck pass locally.
  • I updated the documentation / README where relevant.
  • If this changes how the library is used (API, props, events, behavior), I updated the AI agent skill (skills/react-native-video/) to match. (Not done yet: the skill has no ads page.)
  • This PR is focused on a single concern.
  • I tested my changes on at least one platform, or described under "Test plan" what CI (which runs both) should show.

@Okelm

Okelm commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

thanks @Ibad9 do you have a version with more commits than 1? it's a huge PR and just one commit

@Okelm

Okelm commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

we need to check and merge those first #4825, #5149 #5137

@Ibad9
Ibad9 marked this pull request as draft October 6, 2026 14:37
@Ibad9

Ibad9 commented Oct 6, 2026

Copy link
Copy Markdown
Author

Hi @Okelm currently facing some issues on IOS regarding fullscreen. I have moved this PR to draft for now. I will move the status once tested properly.

Thanks in advance.

@Ibad9
Ibad9 force-pushed the ima-ads-integration branch 3 times, most recently from d0814cc to 0f2acec Compare October 7, 2026 09:54
Adds production client-side pre/mid/post-roll video ad support via
Google's IMA SDK on both platforms, structurally enforced: no content
frame renders before the ad decision resolves, not via a JS-side
check or timer.

- New `VideoAdsConfig` (VideoConfig.ads): adTagUrl, autoActivate,
  language, ppid, vastLoadTimeoutMs, mediaLoadTimeoutMs,
  adRequestTimeoutMs (a fail-open watchdog independent of the SDK's
  own timeouts).
- VideoPlayerBase gains `isPlayingAd`, `adState`
  (idle/activating/requesting/playing/content/failed), and
  `activateAds()`/`deactivateAds()`/`skipAd()`. Activation is
  explicit and separate from construction/play() by design: a
  scrollable feed can mount several players at once (preloaded
  neighbors) with only the visible one requesting an ad -
  `autoActivate: true` is available for the simpler single-player
  case.
- 11 new events: onAdsResolved, onAdBreakStart/End, onAdProgress,
  onAdStart/Complete/Skipped, onAdClicked, onAdError,
  onAllAdsCompleted, onAdStateChange.

New `ios/core/Ads/{AdPlaybackGate,VideoAdController,
GoogleIMAAdController}.swift`. Renders ads through IMA's own internal
player (not by handing the content AVPlayer to IMA) - documented in
GoogleIMAAdController.swift's header, chosen specifically to keep
VideoPlayerObserver's KVO-driven content events (onLoad/onEnd/
onProgress) from misfiring during ad playback, and because the
pre-roll case has `player.currentItem == nil` by construction (the
gate is withholding it) so there is no question of IMA displacing a
nil item. The gate itself lives in `HybridVideoPlayer.commitPlayerItem`,
the single call site that attaches a content item to the player -
withholding it there is what makes "no content frame before the ad
decision" a structural guarantee rather than a convention. PiP is
suppressed for the duration of an ad break (`allowsPictureInPicturePlayback`
toggle) rather than building the full `IMAPictureInPictureProxy`
integration, since this library exposes no app-owned
`AVPictureInPictureController` to hand to it - PiP here is 100%
AVKit-internal.

Two real bugs found and fixed by running an actual vertical feed on
device, not by inspection: a pre-roll played on the first episode of
a feed and never again (deactivate() was permanently disabling the
gate instead of just tearing down the session, so every preloaded-
then-deactivated neighbor cell could never activate again once it
became active); and taps did nothing while an ad was on screen
(onPlaybackStateChange derived isPlaying from the paused content
player instead of the ad's own state, so a play/pause toggle always
picked the wrong branch).

New `android/src/main/java/com/twg/video/core/ads/` (compiled always,
IMA-free interface) plus a separate opt-in `ima` Gradle source set
(`RNVideo_useExoplayerIma` flag) holding the real
`androidx.media3:media3-exoplayer-ima` integration - opt-in because it
pulls `play-services-ads-identifier`, which contributes the AD_ID
permission to the merged manifest, a Data Safety form change that
should not be forced on every consumer of the base package. Enabling
the flag also requires the consuming app to turn on core library
desugaring (`coreLibraryDesugaringEnabled true` +
`desugar_jdk_libs:2.1.5`), which the IMA SDK's own AAR metadata
declares - see example/android/app/build.gradle for the exact setup.
The gate is Media3's own: `AdsMediaSource` withholds its `Timeline`
until the ad decision lands, so ExoPlayer can never create a content
`MediaPeriod` regardless of `playWhenReady`; `HybridVideoPlayer.kt`'s
job is deferring `player.prepare()` itself until `activateAds()` is
called, so the up-to-several preloading neighbors in a feed stay
completely inert (no ImaAdsLoader, no ad request, no decoded frame).
PiP is gated on both entry paths - explicit request (defer-and-retry
once) and the API-31 `autoEnterPictureInPicture` system-driven path,
which bypasses the explicit path entirely.

Two real bugs found the same way: the ad decision was resolving off
ExoPlayer's synchronous placeholder timeline before IMA had actually
answered (opening the gate on nothing); and this media3+IMA version
never dispatches AD_BREAK_STARTED/AD_BREAK_ENDED for
client-side-ad-insertion, only CONTENT_PAUSE_REQUESTED/
CONTENT_RESUME_REQUESTED - both pairs are now handled and deduplicated
so the JS-facing events stay correct regardless of which the SDK
version in use actually fires.

New `example/src/components/AdsManager.tsx`, following the existing
TextTrackManager/VideoQualityManager panel convention: activate/
deactivate/skip buttons, live ad state, and an ad-event log. Wired to
a real public Google sample VAST tag via `videoSource.ts`.

New `docs/docs/player/ads.md`: config reference, activation model
(single-player `autoActivate` vs. feed-driven `activateAds()`/
`deactivateAds()`), ad state, all 11 events with payload shapes,
platform setup for both flags, and a troubleshooting section leading
with the costliest gotcha - forgetting the platform flag produces no
error at all, every ad API just stays present and inert.

Both platforms real-device/simulator verified against Google's public
VAST/VMAP sample tags (not just written and assumed) - confirmed zero
content frames render during pre-roll, full pre/mid/post-roll cycles,
correct fail-open on ad-request failure and on no-fill, and zero
regression against existing playback with no `ads` config present.
Cold builds green on both platforms as of this commit: Android
`assembleDebug` with `useExoplayerIma` both true and false; iOS `pod
install` (IMA linked via `$RNVideoUseGoogleIMA`) + a full `xcodebuild
clean build`. `tsc`/lint clean across the monorepo.

iOS fullscreen: entering or leaving fullscreen while an ad plays no
longer crashes (IMA's child view controller is detached before AVKit
moves the ad container and re-parented afterwards), the ad container is
attached when the ad controller is created after the view, and
fullscreen now follows the phone's rotation (FullscreenOrientationHandler).

iOS: an ad source swapped in while the video view is already on screen no longer skips
the pre-roll (the ad request waits until the ad container has its view controller).

JS: `onAdEvent` is one listener for every ad event, delivered as `{ type, data }`; the
individual onAd* events stay available. The example Ads panel uses it.

`adPodIndex` now means the same on both platforms: the zero-based position of the ad within
its pod (`adPodIndex + 1` of `totalAdsInPod`). iOS sent IMA's one-based `adPosition` and
Android sent the pod's index in the content.

Docs: integration checklist, Google sample tags for testing, and a limitations section.

Agent skill: new `references/v7/ads.md`, and the routing / v6-vs-v7 / events / migration
notes no longer say v7 has no ads.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Ibad9
Ibad9 force-pushed the ima-ads-integration branch from 0f2acec to 9a47a7c Compare October 7, 2026 10:52

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To Triage

Development

Successfully merging this pull request may close these issues.

3 participants