Sample Code for Client Side Ad-Insertion (CSAI)

Using W3CMedia APIs, you can insert pre-roll, mid-roll or post-roll advertisements during main content playback for Client Side Ad Insertion (CSAI). CSAI offers a real-time approach to video and ad delivery, where the client requests ads from an ad server. The client operates two video player objects where the surface component is shared between the main player and ad player. Custom events in the manifest or timed events signal the pausing of main content and start of ad playback.

How It Works

This sample demonstrates mid-roll ad insertion using two VideoPlayer instances:

  1. Main content plays until it reaches the adStart time (10 seconds by default)
  2. Main player pauses and the ad player takes over the video surface
  3. Ad plays for the specified duration (adEnd - 10 seconds by default)
  4. Playback switches back to main content after the ad completes

Technical Details:

  • The mainPlayer uses MSE (Media Source Extensions) APIs for adaptive streaming
  • The adPlayer uses standard video element URL mode for simpler ad playback
  • Both players share the same video surface component
  • Timeline: MainPlayer (adStart) → adPlayer (adEnd) → mainPlayer

Production Features

This sample includes production-ready features:

  • Error handling for player initialization and playback failures
  • Timeout protection for ad loading (10 second default)
  • Automatic fallback to main content if ad fails to load or play
  • Analytics hooks for tracking ad impressions, completions, and errors
  • Network error recovery to ensure main content continues even if ads fail

Known Issue & Workaround

Issue: The loadedmetadata and canplay events are not fired by the ad player. These events normally indicate when the player is ready to start playback.

Workaround: The sample code “pre-warms” the ad player by initializing it at app startup instead of waiting for these events. This ensures the ad player is ready when needed, with minimal impact on memory usage.

Configuration

Customize the sample by modifying these parameters in the initReducer function:

  • mainContent - The main content to be played
  • adUrl - The URL of the ad content
  • adStart - Ad insertion time in the main player’s timeline (in seconds)
  • adEnd - Ad stop time in the ad player’s timeline (in seconds)

Example configuration:

const initReducer = (initialArg: any) => {
  return {
    status: STATES.IDLE,
    adPlayer: null,
    mainPlayer: null,
    shakaPlayer: null,
    cachedSurface: null,
    adEngineTimer: null,
    mainContent: content[0], // main content
    adUrl: content[1].uri, // ad content
    adStart: 10, // Start at 10s in main timeline
    adEnd: 10, // Stop ad at 10s in ad timeline
    adPlayed: false,
    timeoutId: null,
    adLoadTimeoutId: null,
    tick: 0,
  };
};

Sample Code

This sample code has been tested with Vega SDK 0.22.


import * as React from 'react';
import {useRef, useState, useEffect, useReducer} from 'react';
import {
  Platform,
  useWindowDimensions,
  View,
  StyleSheet,
  TouchableOpacity,
  Text,
} from 'react-native';

import {
  VideoPlayer,
  KeplerVideoSurfaceView,
  KeplerCaptionsView,
} from '@amazon-devices/react-native-w3cmedia';

import {ShakaPlayer, ShakaPlayerSettings} from './shakaplayer/ShakaPlayer';

// set to false if app wants to call play API on main video manually
const AUTOPLAY = true;
const DEFAULT_ABR_WIDTH: number = Platform.isTV ? 3840 : 1919;
const DEFAULT_ABR_HEIGHT: number = Platform.isTV ? 2160 : 1079;
const TICK_PERIOD = 250; // in ms
const AD_LOAD_TIMEOUT = 10000; // 10 seconds timeout for ad loading

// Analytics interface - implement with your analytics provider
const Analytics = {
  trackAdImpression: (adUrl: string) => {
    console.log('Analytics: Ad impression tracked', adUrl);
    // TODO: Implement your analytics tracking here
  },
  trackAdComplete: (adUrl: string) => {
    console.log('Analytics: Ad completed', adUrl);
    // TODO: Implement your analytics tracking here
  },
  trackAdError: (adUrl: string, error: string) => {
    console.log('Analytics: Ad error', adUrl, error);
    // TODO: Implement your analytics tracking here
  },
  trackAdSkipped: (adUrl: string, reason: string) => {
    console.log('Analytics: Ad skipped', adUrl, reason);
    // TODO: Implement your analytics tracking here
  },
};

const content = [
  {
    secure: 'false', // true : Use Secure Video Buffers. false: Use Unsecure Video Buffers.
    uri: 'https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-clear-2160/manifest.mpd',
    drm_scheme: '', // com.microsoft.playready, com.widevine.alpha
    drm_license_uri: '', // DRM License acquisition server URL : needed only if the content is DRM protected
  },
  // Ad content
  {
    secure: 'false', // true : Use Secure Video Buffers. false: Use Unsecure Video Buffers.
    uri: 'https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-137.mp4',
    drm_scheme: '', // com.microsoft.playready, com.widevine.alpha
    drm_license_uri: '', // DRM License acquisition server URL : needed only if the content is DRM protected
  },
];

const STATES = {
  IDLE: 'IDLE',
  INIT: 'INIT',
  MAIN_PLAYING: 'MAIN_PLAYING',
  AD_PLAYING: 'AD_PLAYING',
  MAIN_POSTAD_PLAYING: 'MAIN_POSTAD_PLAYING',
  END: 'END',
};

const ACTIONS = {
  PLAY_MAIN: 'PLAY_MAIN',
  PLAY_AD: 'PLAY_AD',
  END_ACTION: 'END_ACTION',
  IDLE_ACTION: 'IDLE_ACTION',
  SET_MAIN_PLAYER: 'SET_MAIN_PLAYER',
  SET_AD_PLAYER: 'SET_AD_PLAYER',
  SET_CACHED_SURFACE: 'SET_CACHED_SURFACE',
  SET_SHAKA_PLAYER: 'SET_SHAKA_PLAYER',
  SET_AD_PLAYED: 'SET_AD_PLAYED',
  SET_TIMEOUT_ID: 'SET_TIMEOUT_ID',
  TICK: 'TICK',
  AD_FAILED: 'AD_FAILED',
};

// Define the reducer function for state transitions
const reducer = (state: any, action: any) => {
  switch (action.type) {
    case ACTIONS.IDLE_ACTION:
      return {...state, status: STATES.IDLE};
    case ACTIONS.PLAY_MAIN:
      if (state.status === STATES.INIT || state.status === STATES.IDLE) {
        console.log('xreducer: ACTION.PLAY_MAIN -> MAIN_PLAYING');
        return {...state, status: STATES.MAIN_PLAYING};
      } else if (state.status === STATES.AD_PLAYING) {
        console.log('xreducer: ACTION.PLAY_MAIN -> POSTAD_PLAYING');
        return {...state, status: STATES.MAIN_POSTAD_PLAYING};
      } else {
        console.log('Invalid PLAY_MAIN action in state = ', state.status);
      }
      break;
    case ACTIONS.PLAY_AD:
      if (state.status === STATES.MAIN_PLAYING) {
        return {...state, status: STATES.AD_PLAYING};
      } else {
        console.log('Invalid PLAY_AD action in state =', state.status);
      }
      break;
    case ACTIONS.AD_FAILED:
      console.log('xreducer: ACTION.AD_FAILED - skipping ad, continuing main content');
      return {...state, adPlayed: true, status: STATES.MAIN_POSTAD_PLAYING};
    case ACTIONS.END_ACTION:
      console.log('xreducer: ACTION.END_ACTION');
      return {...state, status: STATES.END};
    case ACTIONS.TICK:
      console.log('xreducer: ACTION.TICK');
      return {...state, tick: state.tick + 1};
    case ACTIONS.SET_AD_PLAYER:
      console.log('xreducer: ACTION.SET_AD_PLAYER');
      return {...state, adPlayer: action.payload};
    case ACTIONS.SET_MAIN_PLAYER:
      console.log('xreducer: ACTION.SET_MAIN_PLAYER');
      return {...state, mainPlayer: action.payload};
    case ACTIONS.SET_SHAKA_PLAYER:
      console.log('xreducer: ACTION.SET_SHAKA_PLAYER');
      return {...state, shakaPlayer: action.payload};
    case ACTIONS.SET_CACHED_SURFACE:
      console.log('xreducer: ACTION.SET_CACHED_SURFACE');
      return {...state, cachedSurface: action.payload};
    case ACTIONS.SET_TIMEOUT_ID:
      return {...state, timeoutId: action.payload};
    case ACTIONS.SET_AD_PLAYED:
      return {...state, adPlayed: true};
    default:
      console.log('reducer unkonwn action action.type = ', action.type);
  }
  return state;
};

const initReducer = (initialArg: any) => {
  return {
    status: STATES.INIT,
    adPlayer: null,
    mainPlayer: null,
    shakaPlayer: null,
    cachedSurface: null,
    adEngineTimer: null,
    mainContent: content[0], // main content
    adUrl: content[1].uri, // ad content
    adStart: 10, // Start at 10s in main timeline
    adEnd: 10, // Stop ad at 10s in ad timeline
    adPlayed: false,
    timeoutId: null,
    adLoadTimeoutId: null,
    tick: 0,
  };
};

// High level walkthrough
// 1. State machine has INIT, IDLE, MAIN_PLAYING, AD_PLAYING, and MAIN_POSTAD_PLAYING
// 2. INIT state initializes two players, a mse player for main content playback and
//    a url player for ad content playback. If surface not created, go to IDLE else
//    go to MAIN_PLAYING
// 3. IDLE waits for surface to be created
// 4. MAIN_PLAYING is for main content playback before Ad
// 5. AD_PLAYING is for Ad content playback
// 6. MAIN_POSTAD_PLAYING is for main content playback after Ad
// 7. Configuration can be done using the initReducer function above
// 8. Main params to configure are adStart, adEnd times, adUrl content, mainContent

// Define the component
export const App = () => {
  const [buttonPress, setButtonPress] = useState(false);
  const adPlayer = useRef<VideoPlayer | null>(null);
  const mainPlayer = useRef<VideoPlayer | null>(null);
  const shakaPlayer = useRef<any>(null);
  const cachedSurface = useRef<any>(null);
  const timeoutId = useRef<any>(null);
  const adLoadTimeoutId = useRef<any>(null);

  const currShakaPlayerSettings = useRef<ShakaPlayerSettings>({
    secure: false, // Playback goes through secure or non-secure mode
    abrEnabled: true, // Enables Adaptive Bit-Rate (ABR) switching
    abrMaxWidth: DEFAULT_ABR_WIDTH, // Maximum width allowed for ABR
    abrMaxHeight: DEFAULT_ABR_HEIGHT, // Maximum height allowed for ABR
  });

  const [state, dispatch] = useReducer(reducer, null, initReducer);

  // Error handler for ad player
  const onAdError = (error: any): void => {
    console.error('app: Ad player error:', error);
    Analytics.trackAdError(state.adUrl, error.message || 'Unknown error');
    
    // Clear ad load timeout if it exists
    if (adLoadTimeoutId.current) {
      clearTimeout(adLoadTimeoutId.current);
      adLoadTimeoutId.current = null;
    }
    
    // Skip ad and continue with main content
    dispatch({type: ACTIONS.AD_FAILED});
  };

  // Main player callbacks and setup
  const onEndedMain = async () => {
    console.log('app: onEndedMain received');
    dispatch({type: ACTIONS.END_ACTION});
  };

  const onErrorMain = (error: any): void => {
    console.error('app: Main player error:', error);
    // Handle main player errors - could show error UI or retry
  };

  const onPausedMain = (): void => {
    console.log('app: onPausedMain');
    dispatch({type: ACTIONS.PLAY_AD});
  };

  const setupEventListenersMain = (): void => {
    console.log('app: setup event listeners');
    mainPlayer.current?.addEventListener('pause', onPausedMain);
    mainPlayer.current?.addEventListener('ended', onEndedMain);
    mainPlayer.current?.addEventListener('error', onErrorMain);
  };

  const initializeShakaMain = () => {
    console.log('app: in initializeShakaMain()');
    if (mainPlayer.current !== null) {
      shakaPlayer.current = new ShakaPlayer(
        mainPlayer.current,
        currShakaPlayerSettings.current,
      );
    }
    if (shakaPlayer.current !== null) {
      console.log('app: loading main player url');
      shakaPlayer.current.load(state.mainContent, AUTOPLAY); // Main content url set
    }
    dispatch({type: ACTIONS.SET_SHAKA_PLAYER, payload: shakaPlayer});
    console.log('app: initializeShakaMain complete');
  };

  const initializeVideoPlayerMain = async () => {
    console.log('app: calling initializeVideoPlayer');
    try {
      mainPlayer.current = new VideoPlayer();
      // @ts-ignore
      global.gmedia = mainPlayer.current;
      await mainPlayer.current.initialize();
      setupEventListenersMain();
      mainPlayer.current!.autoplay = false;
      initializeShakaMain();
      dispatch({type: ACTIONS.SET_MAIN_PLAYER, payload: mainPlayer});
    } catch (error) {
      console.error('app: Failed to initialize main player:', error);
      // Handle initialization error - could show error UI
    }
  };

  // Ad player callbacks and setup
  const onPausedAd = (): void => {
    console.log('app: onPausedAd');
    
    // Clear ad load timeout
    if (adLoadTimeoutId.current) {
      clearTimeout(adLoadTimeoutId.current);
      adLoadTimeoutId.current = null;
    }
    
    // Track ad completion
    Analytics.trackAdComplete(state.adUrl);
    
    dispatch({type: ACTIONS.PLAY_MAIN});
  };

  const setupEventListenersAd = (): void => {
    console.log('app: setup adPlayer event listeners');
    adPlayer.current?.addEventListener('pause', onPausedAd);
    adPlayer.current?.addEventListener('error', onAdError);
  };

  const initializePlayerAd = async () => {
    console.log('app: calling initializeAdPlayer');
    try {
      adPlayer.current = new VideoPlayer();
      await adPlayer.current.initialize();
      adPlayer.current!.autoplay = false;
      dispatch({type: ACTIONS.SET_AD_PLAYER, payload: adPlayer});
      setupEventListenersAd();
      adPlayer.current.autoplay = false;
      adPlayer.current.src = state.adUrl; // set adPlayer url
    } catch (error) {
      console.error('app: Failed to initialize ad player:', error);
      Analytics.trackAdError(state.adUrl, 'Initialization failed');
      // Ad player initialization failed, but main player can still work
    }
  };

  // Helper functions
  const setSurfaceToMainPlayer = (mainPlayback: boolean) => {
    if (
      adPlayer.current === null ||
      mainPlayer.current === null ||
      cachedSurface.current === null
    ) {
      console.log('app: setSurface adPlayer or mainPlayer or surface is null');
      return;
    }
    if (mainPlayback) {
      // set surface to mainPlayer
      (mainPlayer.current as VideoPlayer).setSurfaceHandle(
        cachedSurface.current,
      );
      console.log('app: setting surface to main player');
    } else {
      // set surface to adPlayer
      (adPlayer.current as VideoPlayer).setSurfaceHandle(cachedSurface.current);
      console.log('app: setting surface to ad player');
    }
  };

  const handleInit = async () => {
    if (mainPlayer.current !== null) {
      console.log(
        'Init complete, return early from handleInit mainPlayer =',
        mainPlayer,
      );
      return;
    }
    await initializeVideoPlayerMain();
    await initializePlayerAd();
    console.log('app: initializePlayers complete');
    if (cachedSurface.current) {
      dispatch({type: ACTIONS.PLAY_MAIN});
    } else {
      // delay playback till surface created
      dispatch({type: ACTIONS.IDLE_ACTION});
    }
  };

  // Cleanup
  const cleanEventListeners = (): void => {
    console.log('app: remove event listeners');
    state.mainPlayer.current?.removeEventListener('ended', onEndedMain);
    state.mainPlayer.current?.removeEventListener('pause', onPausedMain);
    state.mainPlayer.current?.removeEventListener('error', onErrorMain);
    state.adPlayer.current?.removeEventListener('pause', onPausedAd);
    state.adPlayer.current?.removeEventListener('error', onAdError);
  };

  const cleanupPlayers = async () => {
    await state.mainPlayer.current?.deinitialize();
    await state.adPlayer.current?.deinitialize();
    global.gmedia = null;
  };

  // Initialize at component mount
  useEffect(() => {
    console.log('app: useEffect mm initial v1.9');
    mainPlayer.current = null;
    timeoutId.current = null;
    adLoadTimeoutId.current = null;
    shakaPlayer.current = null;
    cachedSurface.current = null;
    return () => {
      console.log('app: useEffect mm cleanup');
      // Clean up timeouts
      if (timeoutId.current) {
        clearInterval(timeoutId.current);
      }
      if (adLoadTimeoutId.current) {
        clearTimeout(adLoadTimeoutId.current);
      }
    };
  }, []);

  // State machine handlers
  const handleMainPlaying = () => {
    if (mainPlayer.current?.paused) {
      setSurfaceToMainPlayer(true);
      console.log('app: main player set surface and call play');
      mainPlayer.current!.play();
    }
    if (state.adPlayed) {
      console.log(
        'app: new state = MAIN_POSTAD_PLAYING, adPlayed is set, return early from handleMainPlaying',
      );
      return;
    }
    console.log('app: adStart = ', state.adStart);
    console.log('app: currentTime  = ', mainPlayer.current!.currentTime);
    console.log(
      'app: new state  = MAIN_PLAYING',
      mainPlayer.current!.currentTime,
    );
    if (mainPlayer.current!.currentTime >= state.adStart) {
      mainPlayer.current!.pause(); // dispatch in pause callback
    }
    if (timeoutId.current === null) {
      console.log('app: create timeout');
      timeoutId.current = setInterval(() => {
        console.log('app: tick');
        dispatch({type: ACTIONS.TICK});
      }, TICK_PERIOD);
      dispatch({type: ACTIONS.SET_TIMEOUT_ID, payload: timeoutId});
    }
  };

  const handleAdPlaying = () => {
    console.log(
      'app: new state = AD_PLAYING, adPlayer.current!.paused = ',
      adPlayer.current!.paused,
    );
    
    if (adPlayer.current!.paused) {
      console.log('app: switch surface to Ad Player and start playback');
      setSurfaceToMainPlayer(false);
      dispatch({type: ACTIONS.SET_AD_PLAYED});
      
      // Track ad impression
      Analytics.trackAdImpression(state.adUrl);
      
      // Set timeout for ad loading
      adLoadTimeoutId.current = setTimeout(() => {
        console.error('app: Ad load timeout - skipping ad');
        Analytics.trackAdSkipped(state.adUrl, 'Load timeout');
        dispatch({type: ACTIONS.AD_FAILED});
      }, AD_LOAD_TIMEOUT);
      
      try {
        adPlayer.current!.play();
      } catch (error) {
        console.error('app: Failed to play ad:', error);
        onAdError(error);
      }
    }
    
    console.log(
      'app: adPlayer.current!.currentTime =',
      adPlayer.current!.currentTime,
    );
    if (adPlayer.current!.currentTime >= state.adEnd) {
      console.log(
        'app: adPlayer.current!.currentTime >= state.adEnd, ad pause called',
      );
      adPlayer.current!.pause(); // dispatch in pause callback
    }
  };

  useEffect(() => {
    switch (state.status) {
      case STATES.INIT:
        console.log('app: new state = INIT');
        handleInit();
        break;
      case STATES.IDLE:
        console.log('app: new state = IDLE');
        break;
      case STATES.MAIN_PLAYING:
        console.log('app: new state = MAIN_PLAYING');
        handleMainPlaying();
        break;
      case STATES.AD_PLAYING:
        console.log('app: new state = AD_PLAYING');
        handleAdPlaying();
        break;
      case STATES.MAIN_POSTAD_PLAYING:
        console.log(
          'app: new state = MAIN_POSTAD_PLAYING, switch surface to main player',
        );
        console.log('app: adPlayed = ', state.adPlayed);
        handleMainPlaying();
        clearInterval(state.timeoutId.current);
        break;
      case STATES.END:
        // cleanup
        state.shakaPlayer.current.unload();
        state.shakaPlayer.current = null;
        cleanEventListeners();
        cleanupPlayers();
        break;
    }
    return () => {
      console.log('app: return effect');
    };
  }, [state]);

  // Surface callbacks
  const onSurfaceViewCreated = (surfaceHandle: string): void => {
    console.log('app: surface created');
    cachedSurface.current = surfaceHandle;
    dispatch({
      type: ACTIONS.SET_CACHED_SURFACE,
      payload: cachedSurface,
    });
    if (state.status === STATES.IDLE) {
      console.log('app: Scheduling main playback from IDLE');
      dispatch({type: ACTIONS.PLAY_MAIN});
    }
  };

  const onSurfaceViewDestroyed = (surfaceHandle: string): void => {
    console.log('app: surface destroyed');
    cachedSurface.current = null;
    mainPlayer.current?.clearSurfaceHandle(surfaceHandle);
  };

  const onCaptionViewCreated = (captionsHandle: string): void => {
    console.log('app: caption view created');
    mainPlayer.current?.setCaptionViewHandle(captionsHandle); // check if needed
  };

  if (!buttonPress) {
    console.log('app: false button press');
    return (
      <View style={styles.container}>
        <TouchableOpacity
          style={styles.button}
          onPress={() => {
            setButtonPress(true);
          }}
          hasTVPreferredFocus={true}
          activeOpacity={1}>
          <Text style={styles.buttonLabel}> Press to Play Video </Text>
        </TouchableOpacity>
      </View>
    );
  } else {
    return (
      <View style={styles.videoContainer}>
        <KeplerVideoSurfaceView
          style={styles.surfaceView}
          onSurfaceViewCreated={onSurfaceViewCreated}
          onSurfaceViewDestroyed={onSurfaceViewDestroyed}
        />
        <KeplerCaptionsView
          onCaptionViewCreated={onCaptionViewCreated}
          style={styles.captionView}
        />
      </View>
    );
  }
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'column',
    backgroundColor: '#283593',
    justifyContent: 'center',
    alignItems: 'center',
  },
  button: {
    alignItems: 'center',
    backgroundColor: '#303030',
    borderColor: 'navy',
    borderRadius: 10,
    borderWidth: 1,
    paddingVertical: 12,
    paddingHorizontal: 32,
  },
  buttonLabel: {
    color: 'white',
    fontSize: 22,
  },
  videoContainer: {
    backgroundColor: 'white',
    alignItems: 'stretch',
  },
  surfaceView: {
    zIndex: 0,
  },
  captionView: {
    width: '100%',
    height: '100%',
    top: 0,
    left: 0,
    position: 'absolute',
    backgroundColor: 'transparent',
    flexDirection: 'column',
    alignItems: 'center',
    zIndex: 2,
  },
});

Last updated: Mar 2, 2026