public final class

ImaServerSideAdInsertionMediaSource

extends CompositeMediaSource<java.lang.Void>

 java.lang.Object

androidx.media3.exoplayer.source.BaseMediaSource

androidx.media3.exoplayer.source.CompositeMediaSource<java.lang.Void>

↳androidx.media3.exoplayer.ima.ImaServerSideAdInsertionMediaSource

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-exoplayer-ima', version: '1.5.0-alpha01'

  • groupId: androidx.media3
  • artifactId: media3-exoplayer-ima
  • version: 1.5.0-alpha01

Artifact androidx.media3:media3-exoplayer-ima:1.5.0-alpha01 it located at Google repository (https://maven.google.com/)

Overview

MediaSource for IMA server side inserted ad streams.

Summary

Methods
public booleancanUpdateMediaItem(MediaItem mediaItem)

public MediaPeriodcreatePeriod(MediaSource.MediaPeriodId id, Allocator allocator, long startPositionUs)

public synchronized MediaItemgetMediaItem()

public voidmaybeThrowSourceInfoRefreshError()

protected abstract voidonChildSourceInfoRefreshed(java.lang.Object childSourceId, MediaSource mediaSource, Timeline newTimeline)

Called when the source info of a child source has been refreshed.

protected abstract voidprepareSourceInternal(TransferListener mediaTransferListener)

Starts source preparation and enables the source, see MediaSource.prepareSource(MediaSource.MediaSourceCaller, TransferListener, PlayerId).

public voidreleasePeriod(MediaPeriod mediaPeriod)

protected abstract voidreleaseSourceInternal()

Releases the source, see MediaSource.releaseSource(MediaSource.MediaSourceCaller).

public synchronized voidupdateMediaItem(MediaItem mediaItem)

from CompositeMediaSource<T>disableChildSource, disableInternal, enableChildSource, enableInternal, getMediaPeriodIdForChildMediaPeriodId, getMediaTimeForChildMediaTime, getWindowIndexForChildWindowIndex, prepareChildSource, releaseChildSource
from BaseMediaSourceaddDrmEventListener, addEventListener, createDrmEventDispatcher, createDrmEventDispatcher, createEventDispatcher, createEventDispatcher, createEventDispatcher, createEventDispatcher, disable, enable, getPlayerId, isEnabled, prepareSource, prepareSource, prepareSourceCalled, refreshSourceInfo, releaseSource, removeDrmEventListener, removeEventListener, setPlayerId
from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Methods

public synchronized MediaItem getMediaItem()

public boolean canUpdateMediaItem(MediaItem mediaItem)

public synchronized void updateMediaItem(MediaItem mediaItem)

protected abstract void prepareSourceInternal(TransferListener mediaTransferListener)

Starts source preparation and enables the source, see MediaSource.prepareSource(MediaSource.MediaSourceCaller, TransferListener, PlayerId). This method is called at most once until the next call to BaseMediaSource.releaseSourceInternal().

Parameters:

mediaTransferListener: The transfer listener which should be informed of any media data transfers. May be null if no listener is available. Note that this listener should usually be only informed of transfers related to the media loads and not of auxiliary loads for manifests and other data.

protected abstract void onChildSourceInfoRefreshed(java.lang.Object childSourceId, MediaSource mediaSource, Timeline newTimeline)

Called when the source info of a child source has been refreshed.

Parameters:

childSourceId: The unique id used to prepare the child source.
mediaSource: The child source whose source info has been refreshed.
newTimeline: The timeline of the child source.

public MediaPeriod createPeriod(MediaSource.MediaPeriodId id, Allocator allocator, long startPositionUs)

public void releasePeriod(MediaPeriod mediaPeriod)

public void maybeThrowSourceInfoRefreshError()

protected abstract void releaseSourceInternal()

Releases the source, see MediaSource.releaseSource(MediaSource.MediaSourceCaller). This method is called exactly once after each call to BaseMediaSource.prepareSourceInternal(TransferListener).

Source

/*
 * Copyright (C) 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.media3.exoplayer.ima;

import static androidx.media3.common.AdPlaybackState.AD_STATE_AVAILABLE;
import static androidx.media3.common.AdPlaybackState.AD_STATE_UNAVAILABLE;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Util.msToUs;
import static androidx.media3.common.util.Util.usToMs;
import static androidx.media3.exoplayer.ima.ImaUtil.addLiveAdBreak;
import static androidx.media3.exoplayer.ima.ImaUtil.expandAdGroupPlaceholder;
import static androidx.media3.exoplayer.ima.ImaUtil.getAdGroupAndIndexInLiveMultiPeriodTimeline;
import static androidx.media3.exoplayer.ima.ImaUtil.getAdGroupAndIndexInVodMultiPeriodTimeline;
import static androidx.media3.exoplayer.ima.ImaUtil.getAdGroupDurationUsForLiveAdPeriodIndex;
import static androidx.media3.exoplayer.ima.ImaUtil.getWindowStartTimeUs;
import static androidx.media3.exoplayer.ima.ImaUtil.handleAdPeriodRemovedFromTimeline;
import static androidx.media3.exoplayer.ima.ImaUtil.maybeCorrectPreviouslyUnknownAdDurations;
import static androidx.media3.exoplayer.ima.ImaUtil.secToMsRounded;
import static androidx.media3.exoplayer.ima.ImaUtil.secToUsRounded;
import static androidx.media3.exoplayer.ima.ImaUtil.splitAdGroup;
import static androidx.media3.exoplayer.ima.ImaUtil.splitAdPlaybackStateForPeriods;
import static androidx.media3.exoplayer.ima.ImaUtil.updateAdDurationInAdGroup;
import static androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState;
import static com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType.LOADED;

import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Pair;
import android.view.ViewGroup;
import androidx.annotation.GuardedBy;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.AdOverlayInfo;
import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.AdViewProvider;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Metadata;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
import androidx.media3.exoplayer.ima.ImaUtil.ServerSideAdInsertionConfiguration;
import androidx.media3.exoplayer.source.CompositeMediaSource;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.ForwardingTimeline;
import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource;
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource.AdPlaybackStateUpdater;
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import androidx.media3.exoplayer.upstream.Loader;
import androidx.media3.exoplayer.upstream.Loader.LoadErrorAction;
import androidx.media3.exoplayer.upstream.Loader.Loadable;
import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import com.google.ads.interactivemedia.v3.api.Ad;
import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
import com.google.ads.interactivemedia.v3.api.AdErrorEvent;
import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener;
import com.google.ads.interactivemedia.v3.api.AdEvent;
import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener;
import com.google.ads.interactivemedia.v3.api.AdPodInfo;
import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener;
import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
import com.google.ads.interactivemedia.v3.api.CompanionAdSlot;
import com.google.ads.interactivemedia.v3.api.CuePoint;
import com.google.ads.interactivemedia.v3.api.ImaSdkFactory;
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
import com.google.ads.interactivemedia.v3.api.StreamDisplayContainer;
import com.google.ads.interactivemedia.v3.api.StreamManager;
import com.google.ads.interactivemedia.v3.api.StreamRequest;
import com.google.ads.interactivemedia.v3.api.StreamRequest.StreamFormat;
import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate;
import com.google.ads.interactivemedia.v3.api.player.VideoStreamPlayer;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** MediaSource for IMA server side inserted ad streams. */
public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSource<Void> {

  /** A listener to be notified of stream events. */
  @UnstableApi
  public interface StreamEventListener {

    /**
     * Called when the {@linkplain StreamManager#getStreamId() stream ID} provided by the IMA SDK
     * changed.
     *
     * <p>This method is called on the main thread.
     *
     * @param mediaItem The media item that the source resolved to the given stream ID.
     * @param streamId The stream ID.
     */
    void onStreamIdChanged(MediaItem mediaItem, String streamId);
  }

  /**
   * Factory for creating {@link ImaServerSideAdInsertionMediaSource
   * ImaServerSideAdInsertionMediaSources}.
   *
   * <p>Apps can use the {@link ImaServerSideAdInsertionMediaSource.Factory} to customized the
   * {@link DefaultMediaSourceFactory} that is used to build a player:
   */
  public static final class Factory implements MediaSource.Factory {

    private final AdsLoader adsLoader;
    private final MediaSource.Factory contentMediaSourceFactory;

    /**
     * Creates a new factory for {@link ImaServerSideAdInsertionMediaSource
     * ImaServerSideAdInsertionMediaSources}.
     *
     * @param adsLoader The {@link AdsLoader}.
     * @param contentMediaSourceFactory The content media source factory to create content sources.
     */
    public Factory(AdsLoader adsLoader, MediaSource.Factory contentMediaSourceFactory) {
      this.adsLoader = adsLoader;
      this.contentMediaSourceFactory = contentMediaSourceFactory;
    }

    @UnstableApi
    @CanIgnoreReturnValue
    @Override
    public MediaSource.Factory setLoadErrorHandlingPolicy(
        LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
      contentMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy);
      return this;
    }

    @UnstableApi
    @CanIgnoreReturnValue
    @Override
    public MediaSource.Factory setDrmSessionManagerProvider(
        DrmSessionManagerProvider drmSessionManagerProvider) {
      contentMediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider);
      return this;
    }

    @UnstableApi
    @Override
    public @C.ContentType int[] getSupportedTypes() {
      return contentMediaSourceFactory.getSupportedTypes();
    }

    @UnstableApi
    @Override
    public MediaSource createMediaSource(MediaItem mediaItem) {
      checkNotNull(mediaItem.localConfiguration);
      Player player = checkNotNull(adsLoader.player);
      Uri streamRequestUri = checkNotNull(mediaItem.localConfiguration).uri;
      StreamRequest streamRequest =
          ImaServerSideAdInsertionUriBuilder.createStreamRequest(streamRequestUri);
      StreamPlayer streamPlayer = new StreamPlayer(player, mediaItem, streamRequest);
      ImaSdkFactory imaSdkFactory = ImaSdkFactory.getInstance();
      StreamDisplayContainer streamDisplayContainer =
          createStreamDisplayContainer(imaSdkFactory, adsLoader.configuration, streamPlayer);
      com.google.ads.interactivemedia.v3.api.AdsLoader imaAdsLoader =
          imaSdkFactory.createAdsLoader(
              adsLoader.context, adsLoader.configuration.imaSdkSettings, streamDisplayContainer);
      ImaServerSideAdInsertionMediaSource mediaSource =
          new ImaServerSideAdInsertionMediaSource(
              player,
              mediaItem,
              streamRequest,
              adsLoader,
              imaAdsLoader,
              streamPlayer,
              contentMediaSourceFactory);
      adsLoader.addMediaSourceResources(mediaSource, streamPlayer, imaAdsLoader);
      return mediaSource;
    }
  }

  /** An ads loader for IMA server side ad insertion streams. */
  public static final class AdsLoader {

    /** Builder for building an {@link AdsLoader}. */
    public static final class Builder {

      private final Context context;
      private final AdViewProvider adViewProvider;

      @Nullable private ImaSdkSettings imaSdkSettings;
      private StreamEventListener streamEventListener;
      @Nullable private AdEventListener adEventListener;
      @Nullable private AdErrorEvent.AdErrorListener adErrorListener;
      private State state;
      private ImmutableList<CompanionAdSlot> companionAdSlots;
      private boolean focusSkipButtonWhenAvailable;

      /**
       * Creates an instance.
       *
       * @param context A context.
       * @param adViewProvider A provider for {@link ViewGroup} instances.
       */
      public Builder(Context context, AdViewProvider adViewProvider) {
        this.context = context;
        this.adViewProvider = adViewProvider;
        companionAdSlots = ImmutableList.of();
        state = new State(ImmutableMap.of());
        focusSkipButtonWhenAvailable = true;
        streamEventListener =
            (mediaItem, streamId) -> {
              // Do nothing.
            };
      }

      /**
       * Sets the IMA SDK settings.
       *
       * <p>If this method is not called, the {@linkplain ImaSdkFactory#createImaSdkSettings()
       * default settings} will be used with the language set to {@linkplain
       * Util#getSystemLanguageCodes() the preferred system language}.
       *
       * @param imaSdkSettings The {@link ImaSdkSettings}.
       * @return This builder, for convenience.
       */
      @UnstableApi
      @CanIgnoreReturnValue
      public AdsLoader.Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
        this.imaSdkSettings = imaSdkSettings;
        return this;
      }

      /**
       * Sets the optional {@link StreamEventListener} that will be called for stream events.
       *
       * @param streamEventListener The stream event listener.
       * @return This builder, for convenience.
       */
      @UnstableApi
      @CanIgnoreReturnValue
      public AdsLoader.Builder setStreamEventListener(StreamEventListener streamEventListener) {
        this.streamEventListener = streamEventListener;
        return this;
      }

      /**
       * Sets the optional {@link AdEventListener} that will be passed to {@link
       * StreamManager#addAdEventListener(AdEventListener)} when the stream manager becomes
       * available.
       *
       * <p>Note: This method can be considered a stable API as long as the {@link AdEventListener}
       * is provided by the IMA library. We can't declare this method stable because we don't have
       * the same guarantee from the library we depend on.
       *
       * @param adEventListener The ad event listener.
       * @return This builder, for convenience.
       */
      @UnstableApi
      @CanIgnoreReturnValue
      public AdsLoader.Builder setAdEventListener(AdEventListener adEventListener) {
        this.adEventListener = adEventListener;
        return this;
      }

      /**
       * Sets the optional {@link AdErrorEvent.AdErrorListener} that will be passed to {@link
       * StreamManager#addAdErrorListener(AdErrorEvent.AdErrorListener)} when the stream manager
       * becomes available.
       *
       * <p>Note: This method can be considered a stable API as long as the {@link
       * AdErrorEvent.AdErrorListener} is provided by the IMA library. We can't declare this method
       * stable because we don't have the same guarantee from the library we depend on.
       *
       * @param adErrorListener The {@link AdErrorEvent.AdErrorListener}.
       * @return This builder, for convenience.
       */
      @UnstableApi
      @CanIgnoreReturnValue
      public AdsLoader.Builder setAdErrorListener(AdErrorEvent.AdErrorListener adErrorListener) {
        this.adErrorListener = adErrorListener;
        return this;
      }

      /**
       * Sets the slots to use for companion ads, if they are present in the loaded ad.
       *
       * @param companionAdSlots The slots to use for companion ads.
       * @return This builder, for convenience.
       * @see AdDisplayContainer#setCompanionSlots(Collection)
       */
      @UnstableApi
      @CanIgnoreReturnValue
      public AdsLoader.Builder setCompanionAdSlots(Collection<CompanionAdSlot> companionAdSlots) {
        this.companionAdSlots = ImmutableList.copyOf(companionAdSlots);
        return this;
      }

      /**
       * Sets the optional state to resume with.
       *
       * <p>The state can be received when {@link #release() releasing} the {@link AdsLoader}.
       *
       * @param state The state to resume with.
       * @return This builder, for convenience.
       */
      @CanIgnoreReturnValue
      public AdsLoader.Builder setAdsLoaderState(State state) {
        this.state = state;
        return this;
      }

      /**
       * Sets whether to focus the skip button (when available) on Android TV devices. The default
       * setting is {@code true}.
       *
       * @param focusSkipButtonWhenAvailable Whether to focus the skip button (when available) on
       *     Android TV devices.
       * @return This builder, for convenience.
       * @see AdsRenderingSettings#setFocusSkipButtonWhenAvailable(boolean)
       */
      @UnstableApi
      @CanIgnoreReturnValue
      public AdsLoader.Builder setFocusSkipButtonWhenAvailable(
          boolean focusSkipButtonWhenAvailable) {
        this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable;
        return this;
      }

      /** Returns a new {@link AdsLoader}. */
      public AdsLoader build() {
        @Nullable ImaSdkSettings imaSdkSettings = this.imaSdkSettings;
        if (imaSdkSettings == null) {
          imaSdkSettings = ImaSdkFactory.getInstance().createImaSdkSettings();
          imaSdkSettings.setLanguage(Util.getSystemLanguageCodes()[0]);
        }
        ServerSideAdInsertionConfiguration configuration =
            new ServerSideAdInsertionConfiguration(
                adViewProvider,
                imaSdkSettings,
                streamEventListener,
                adEventListener,
                adErrorListener,
                companionAdSlots,
                focusSkipButtonWhenAvailable,
                imaSdkSettings.isDebugMode());
        return new AdsLoader(context, configuration, state);
      }
    }

    /** The state of the {@link AdsLoader} that can be used when resuming from the background. */
    public static class State {

      private final ImmutableMap<String, AdPlaybackState> adPlaybackStates;

      @VisibleForTesting
      /* package */ State(ImmutableMap<String, AdPlaybackState> adPlaybackStates) {
        this.adPlaybackStates = adPlaybackStates;
      }

      @Override
      public boolean equals(@Nullable Object o) {
        if (this == o) {
          return true;
        }
        if (!(o instanceof State)) {
          return false;
        }
        State state = (State) o;
        return adPlaybackStates.equals(state.adPlaybackStates);
      }

      @Override
      public int hashCode() {
        return adPlaybackStates.hashCode();
      }

      private static final String FIELD_AD_PLAYBACK_STATES = Util.intToStringMaxRadix(1);

      public Bundle toBundle() {
        Bundle bundle = new Bundle();
        Bundle adPlaybackStatesBundle = new Bundle();
        for (Map.Entry<String, AdPlaybackState> entry : adPlaybackStates.entrySet()) {
          adPlaybackStatesBundle.putBundle(entry.getKey(), entry.getValue().toBundle());
        }
        bundle.putBundle(FIELD_AD_PLAYBACK_STATES, adPlaybackStatesBundle);
        return bundle;
      }

      /** Restores a {@code State} from a {@link Bundle}. */
      public static State fromBundle(Bundle bundle) {
        @Nullable
        ImmutableMap.Builder<String, AdPlaybackState> adPlaybackStateMap =
            new ImmutableMap.Builder<>();
        Bundle adPlaybackStateBundle = checkNotNull(bundle.getBundle(FIELD_AD_PLAYBACK_STATES));
        for (String key : adPlaybackStateBundle.keySet()) {
          AdPlaybackState adPlaybackState =
              AdPlaybackState.fromBundle(checkNotNull(adPlaybackStateBundle.getBundle(key)));
          adPlaybackStateMap.put(
              key, AdPlaybackState.fromAdPlaybackState(/* adsId= */ key, adPlaybackState));
        }
        return new State(adPlaybackStateMap.buildOrThrow());
      }
    }

    private final ServerSideAdInsertionConfiguration configuration;
    private final Context context;
    private final Map<String, MediaSourceResourceHolder> mediaSourceResources;
    private final Map<String, AdPlaybackState> adPlaybackStateMap;

    @Nullable private Player player;

    private AdsLoader(
        Context context, ServerSideAdInsertionConfiguration configuration, State state) {
      this.context = context.getApplicationContext();
      this.configuration = configuration;
      mediaSourceResources = new HashMap<>();
      adPlaybackStateMap = new HashMap<>();
      for (Map.Entry<String, AdPlaybackState> entry : state.adPlaybackStates.entrySet()) {
        adPlaybackStateMap.put(entry.getKey(), entry.getValue());
      }
    }

    /**
     * Sets the player.
     *
     * <p>This method needs to be called before adding server side ad insertion media items to the
     * player.
     */
    public void setPlayer(Player player) {
      this.player = player;
    }

    /**
     * Replaces all the ad tag parameters used for the upcoming ad requests for a live stream.
     *
     * @see StreamManager#replaceAdTagParameters(Map<String, String>)
     */
    @UnstableApi
    public void replaceAdTagParameters(Map<String, String> adTagParameters) {
      if (player == null) {
        return;
      }
      if (player.getPlaybackState() != Player.STATE_IDLE
          && player.getPlaybackState() != Player.STATE_ENDED
          && player.getMediaItemCount() > 0) {
        int currentPeriodIndex = player.getCurrentPeriodIndex();
        Object adsId =
            player
                .getCurrentTimeline()
                .getPeriod(currentPeriodIndex, new Timeline.Period())
                .getAdsId();
        if (adsId instanceof String) {
          MediaSourceResourceHolder mediaSourceResourceHolder = mediaSourceResources.get(adsId);
          if (mediaSourceResourceHolder != null
              && mediaSourceResourceHolder.imaServerSideAdInsertionMediaSource.streamManager
                  != null) {
            mediaSourceResourceHolder.imaServerSideAdInsertionMediaSource.streamManager
                .replaceAdTagParameters(adTagParameters);
          }
        }
      }
    }

    /**
     * Puts the focus on the skip button, if a skip button is present and an ad is playing.
     *
     * @see StreamManager#focus()
     */
    @UnstableApi
    public void focusSkipButton() {
      if (player == null) {
        return;
      }
      if (player.getPlaybackState() != Player.STATE_IDLE
          && player.getPlaybackState() != Player.STATE_ENDED
          && player.getMediaItemCount() > 0) {
        int currentPeriodIndex = player.getCurrentPeriodIndex();
        Object adsId =
            player
                .getCurrentTimeline()
                .getPeriod(currentPeriodIndex, new Timeline.Period())
                .getAdsId();
        if (adsId instanceof String) {
          MediaSourceResourceHolder mediaSourceResourceHolder = mediaSourceResources.get(adsId);
          if (mediaSourceResourceHolder != null
              && mediaSourceResourceHolder.imaServerSideAdInsertionMediaSource.streamManager
                  != null) {
            mediaSourceResourceHolder.imaServerSideAdInsertionMediaSource.streamManager.focus();
          }
        }
      }
    }

    /**
     * Releases resources.
     *
     * @return The {@link State} that can be used when resuming from the background.
     */
    public State release() {
      for (MediaSourceResourceHolder resourceHolder : mediaSourceResources.values()) {
        resourceHolder.streamPlayer.release();
        resourceHolder.imaServerSideAdInsertionMediaSource.setStreamManager(
            /* streamManager= */ null);
        resourceHolder.adsLoader.release();
      }
      State state = new State(ImmutableMap.copyOf(adPlaybackStateMap));
      adPlaybackStateMap.clear();
      mediaSourceResources.clear();
      player = null;
      return state;
    }

    // Internal methods.

    private void addMediaSourceResources(
        ImaServerSideAdInsertionMediaSource mediaSource,
        StreamPlayer streamPlayer,
        com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) {
      mediaSourceResources.put(
          mediaSource.adsId, new MediaSourceResourceHolder(mediaSource, streamPlayer, adsLoader));
    }

    private AdPlaybackState getAdPlaybackState(String adsId) {
      @Nullable AdPlaybackState adPlaybackState = adPlaybackStateMap.get(adsId);
      return adPlaybackState != null ? adPlaybackState : AdPlaybackState.NONE;
    }

    private void setAdPlaybackState(String adsId, AdPlaybackState adPlaybackState) {
      this.adPlaybackStateMap.put(adsId, adPlaybackState);
    }

    private static final class MediaSourceResourceHolder {
      public final ImaServerSideAdInsertionMediaSource imaServerSideAdInsertionMediaSource;
      public final StreamPlayer streamPlayer;
      public final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader;

      private MediaSourceResourceHolder(
          ImaServerSideAdInsertionMediaSource imaServerSideAdInsertionMediaSource,
          StreamPlayer streamPlayer,
          com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) {
        this.imaServerSideAdInsertionMediaSource = imaServerSideAdInsertionMediaSource;
        this.streamPlayer = streamPlayer;
        this.adsLoader = adsLoader;
      }
    }
  }

  private static final String TAG = "ImaSSAIMediaSource";

  private final Player player;
  private final MediaSource.Factory contentMediaSourceFactory;
  private final AdsLoader adsLoader;
  private final com.google.ads.interactivemedia.v3.api.AdsLoader sdkAdsLoader;
  private final StreamEventListener streamEventListener;
  @Nullable private final AdEventListener applicationAdEventListener;
  @Nullable private final AdErrorListener applicationAdErrorListener;
  private final boolean isLiveStream;
  private final String adsId;
  private final StreamRequest streamRequest;
  private final int loadVideoTimeoutMs;
  private final StreamPlayer streamPlayer;
  private final Handler mainHandler;
  private final ComponentListener componentListener;

  @Nullable private Loader loader;
  @Nullable private StreamManager streamManager;
  @Nullable private String streamId;
  @Nullable private ServerSideAdInsertionMediaSource serverSideAdInsertionMediaSource;
  @Nullable private IOException loadError;
  @Nullable private Timeline contentTimeline;
  private AdPlaybackState adPlaybackState;

  @GuardedBy("this")
  private MediaItem mediaItem;

  private ImaServerSideAdInsertionMediaSource(
      Player player,
      MediaItem mediaItem,
      StreamRequest streamRequest,
      AdsLoader adsLoader,
      com.google.ads.interactivemedia.v3.api.AdsLoader sdkAdsLoader,
      StreamPlayer streamPlayer,
      MediaSource.Factory contentMediaSourceFactory) {
    this.player = player;
    this.mediaItem = mediaItem;
    this.streamRequest = streamRequest;
    this.adsLoader = adsLoader;
    this.sdkAdsLoader = sdkAdsLoader;
    this.streamPlayer = streamPlayer;
    this.contentMediaSourceFactory = contentMediaSourceFactory;
    this.streamEventListener = adsLoader.configuration.streamEventListener;
    this.applicationAdEventListener = adsLoader.configuration.applicationAdEventListener;
    this.applicationAdErrorListener = adsLoader.configuration.applicationAdErrorListener;
    Assertions.checkArgument(player.getApplicationLooper() == Looper.getMainLooper());
    mainHandler = new Handler(Looper.getMainLooper());
    Uri streamRequestUri = checkNotNull(mediaItem.localConfiguration).uri;
    isLiveStream = ImaServerSideAdInsertionUriBuilder.isLiveStream(streamRequestUri);
    adsId = ImaServerSideAdInsertionUriBuilder.getAdsId(streamRequestUri);
    loadVideoTimeoutMs = ImaServerSideAdInsertionUriBuilder.getLoadVideoTimeoutMs(streamRequestUri);
    streamRequest = ImaServerSideAdInsertionUriBuilder.createStreamRequest(streamRequestUri);
    boolean isDashStream = Objects.equals(streamRequest.getFormat(), StreamFormat.DASH);
    componentListener =
        new ComponentListener(
            isLiveStream
                ? (isDashStream
                    ? new MultiPeriodLiveAdEventListener()
                    : new SinglePeriodLiveAdEventListener())
                : new VodAdEventListener());
    adPlaybackState = adsLoader.getAdPlaybackState(adsId);
  }

  @UnstableApi
  @Override
  public synchronized MediaItem getMediaItem() {
    return mediaItem;
  }

  @UnstableApi
  @Override
  public boolean canUpdateMediaItem(MediaItem mediaItem) {
    MediaItem existingMediaItem = getMediaItem();
    MediaItem.LocalConfiguration existingConfiguration =
        checkNotNull(existingMediaItem.localConfiguration);
    @Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration;
    return newConfiguration != null
        && newConfiguration.uri.equals(existingConfiguration.uri)
        && newConfiguration.streamKeys.equals(existingConfiguration.streamKeys)
        && Util.areEqual(newConfiguration.customCacheKey, existingConfiguration.customCacheKey)
        && Util.areEqual(newConfiguration.drmConfiguration, existingConfiguration.drmConfiguration)
        && existingMediaItem.liveConfiguration.equals(mediaItem.liveConfiguration);
  }

  @UnstableApi
  @Override
  public synchronized void updateMediaItem(MediaItem mediaItem) {
    this.mediaItem = mediaItem;
  }

  @UnstableApi
  @Override
  public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
    mainHandler.post(() -> assertSingleInstanceInPlaylist(checkNotNull(player)));
    super.prepareSourceInternal(mediaTransferListener);
    if (loader == null) {
      Loader loader = new Loader("ImaServerSideAdInsertionMediaSource");
      player.addListener(componentListener);
      StreamManagerLoadable streamManagerLoadable =
          new StreamManagerLoadable(
              sdkAdsLoader,
              /* imaServerSideAdInsertionMediaSource= */ this,
              streamRequest,
              streamPlayer,
              applicationAdErrorListener);
      loader.startLoading(
          streamManagerLoadable,
          new StreamManagerLoadableCallback(),
          /* defaultMinRetryCount= */ 0);
      this.loader = loader;
    }
  }

  @UnstableApi
  @Override
  protected void onChildSourceInfoRefreshed(
      Void childSourceId, MediaSource mediaSource, Timeline newTimeline) {
    MediaItem mediaItem = getMediaItem();
    refreshSourceInfo(
        new ForwardingTimeline(newTimeline) {
          @Override
          public Window getWindow(
              int windowIndex, Window window, long defaultPositionProjectionUs) {
            newTimeline.getWindow(windowIndex, window, defaultPositionProjectionUs);
            window.mediaItem = mediaItem;
            return window;
          }
        });
  }

  @UnstableApi
  @Override
  public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) {
    return checkNotNull(serverSideAdInsertionMediaSource)
        .createPeriod(id, allocator, startPositionUs);
  }

  @UnstableApi
  @Override
  public void releasePeriod(MediaPeriod mediaPeriod) {
    checkNotNull(serverSideAdInsertionMediaSource).releasePeriod(mediaPeriod);
  }

  @UnstableApi
  @Override
  public void maybeThrowSourceInfoRefreshError() throws IOException {
    super.maybeThrowSourceInfoRefreshError();
    if (loadError != null) {
      IOException loadError = this.loadError;
      this.loadError = null;
      throw loadError;
    }
  }

  @UnstableApi
  @Override
  protected void releaseSourceInternal() {
    super.releaseSourceInternal();
    if (loader != null) {
      loader.release();
      mainHandler.post(
          () -> {
            player.removeListener(componentListener);
            setStreamManager(/* streamManager= */ null);
          });
      loader = null;
    }
    contentTimeline = null;
    serverSideAdInsertionMediaSource = null;
  }

  // Internal methods (called on the main thread).

  @MainThread
  private void setStreamManager(@Nullable StreamManager streamManager) {
    if (this.streamManager == streamManager) {
      return;
    }
    if (this.streamManager != null) {
      if (applicationAdEventListener != null) {
        this.streamManager.removeAdEventListener(applicationAdEventListener);
      }
      if (applicationAdErrorListener != null) {
        this.streamManager.removeAdErrorListener(applicationAdErrorListener);
      }
      this.streamManager.removeAdEventListener(componentListener);
      this.streamManager.destroy();
      streamId = null;
    }
    this.streamManager = streamManager;
    if (streamManager != null) {
      String newStreamId = streamManager.getStreamId();
      if (!Objects.equals(streamId, newStreamId)) {
        streamId = newStreamId;
        streamEventListener.onStreamIdChanged(getMediaItem(), newStreamId);
      }
      streamManager.addAdEventListener(componentListener);
      if (applicationAdEventListener != null) {
        streamManager.addAdEventListener(applicationAdEventListener);
      }
      if (applicationAdErrorListener != null) {
        streamManager.addAdErrorListener(applicationAdErrorListener);
      }
      AdsRenderingSettings adsRenderingSettings =
          ImaSdkFactory.getInstance().createAdsRenderingSettings();
      adsRenderingSettings.setLoadVideoTimeout(loadVideoTimeoutMs);
      adsRenderingSettings.setFocusSkipButtonWhenAvailable(
          adsLoader.configuration.focusSkipButtonWhenAvailable);
      streamManager.init(adsRenderingSettings);
    }
  }

  @MainThread
  private void setAdPlaybackState(AdPlaybackState adPlaybackState) {
    if (adPlaybackState.equals(this.adPlaybackState)) {
      return;
    }
    this.adPlaybackState = adPlaybackState;
    invalidateServerSideAdInsertionAdPlaybackState();
  }

  @MainThread
  private void setContentTimeline(Timeline contentTimeline) {
    if (contentTimeline.equals(this.contentTimeline)) {
      return;
    }
    if (isLiveStream && Objects.equals(streamRequest.getFormat(), StreamFormat.DASH)) {
      // If the ad started playing while the corresponding period in the timeline had an unknown
      // duration, the ad duration is estimated and needs to be corrected when the actual duration
      // is reported.
      adPlaybackState = maybeCorrectPreviouslyUnknownAdDurations(contentTimeline, adPlaybackState);
    }
    this.contentTimeline = contentTimeline;
    invalidateServerSideAdInsertionAdPlaybackState();
  }

  @MainThread
  private void invalidateServerSideAdInsertionAdPlaybackState() {
    if (!adPlaybackState.equals(AdPlaybackState.NONE)
        && contentTimeline != null
        && serverSideAdInsertionMediaSource != null) {
      Timeline contentTimeline = checkNotNull(this.contentTimeline);
      ImmutableMap<Object, AdPlaybackState> splitAdPlaybackStates;
      if (Objects.equals(streamRequest.getFormat(), StreamFormat.DASH)) {
        // DASH ad groups are always split by period.
        splitAdPlaybackStates = splitAdPlaybackStateForPeriods(adPlaybackState, contentTimeline);
      } else {
        // The HLS single period timeline for VOD and live must not be split.
        int firstPeriodIndex =
            contentTimeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).firstPeriodIndex;
        Object periodUid =
            checkNotNull(
                contentTimeline.getPeriod(
                        firstPeriodIndex, new Timeline.Period(), /* setIds= */ true)
                    .uid);
        splitAdPlaybackStates = ImmutableMap.of(periodUid, adPlaybackState);
      }
      streamPlayer.setAdPlaybackStates(adsId, splitAdPlaybackStates, contentTimeline);
      checkNotNull(serverSideAdInsertionMediaSource)
          .setAdPlaybackStates(splitAdPlaybackStates, contentTimeline);
      if (!isLiveStream) {
        adsLoader.setAdPlaybackState(adsId, adPlaybackState);
      }
    }
  }

  // Internal methods (called on the playback thread).

  private void setContentUri(Uri contentUri) {
    if (serverSideAdInsertionMediaSource == null) {
      MediaItem mediaItem = getMediaItem();
      MediaItem contentMediaItem =
          new MediaItem.Builder()
              .setUri(contentUri)
              .setDrmConfiguration(checkNotNull(mediaItem.localConfiguration).drmConfiguration)
              .setLiveConfiguration(mediaItem.liveConfiguration)
              .setCustomCacheKey(mediaItem.localConfiguration.customCacheKey)
              .setStreamKeys(mediaItem.localConfiguration.streamKeys)
              .build();
      ServerSideAdInsertionMediaSource serverSideAdInsertionMediaSource =
          new ServerSideAdInsertionMediaSource(
              contentMediaSourceFactory.createMediaSource(contentMediaItem), componentListener);
      this.serverSideAdInsertionMediaSource = serverSideAdInsertionMediaSource;
      if (isLiveStream) {
        mainHandler.post(
            () ->
                setAdPlaybackState(
                    new AdPlaybackState(adsId).withLivePostrollPlaceholderAppended()));
      }
      prepareChildSource(/* id= */ null, serverSideAdInsertionMediaSource);
    }
  }

  // Static methods.

  @SuppressWarnings("deprecation") // b/192231683 prevents using non-deprecated method
  private static AdPlaybackState setVodAdGroupPlaceholders(
      List<CuePoint> cuePoints, AdPlaybackState adPlaybackState) {
    // TODO(b/192231683) Use getEndTimeMs()/getStartTimeMs() after jar target was removed
    for (int i = 0; i < cuePoints.size(); i++) {
      CuePoint cuePoint = cuePoints.get(i);
      long fromPositionUs = msToUs(secToMsRounded(cuePoint.getStartTime()));
      adPlaybackState =
          addAdGroupToAdPlaybackState(
              adPlaybackState,
              /* fromPositionUs= */ fromPositionUs,
              /* contentResumeOffsetUs= */ 0,
              /* adDurationsUs...= */ getAdDuration(
                  /* startTimeSeconds= */ cuePoint.getStartTime(),
                  /* endTimeSeconds= */ cuePoint.getEndTime()));
    }
    return adPlaybackState;
  }

  private static long getAdDuration(double startTimeSeconds, double endTimeSeconds) {
    // startTimeSeconds and endTimeSeconds that are coming from the SDK, only have a precision of
    // milliseconds so everything that is below a millisecond can be safely considered as coming
    // from rounding issues.
    return msToUs(secToMsRounded(endTimeSeconds - startTimeSeconds));
  }

  private static AdPlaybackState setVodAdInPlaceholder(Ad ad, AdPlaybackState adPlaybackState) {
    AdPodInfo adPodInfo = ad.getAdPodInfo();
    // Handle post rolls that have a podIndex of -1.
    int adGroupIndex =
        adPodInfo.getPodIndex() == -1 ? adPlaybackState.adGroupCount - 1 : adPodInfo.getPodIndex();
    AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex);
    int adIndexInAdGroup = adPodInfo.getAdPosition() - 1;
    if (adGroup.count < adPodInfo.getTotalAds()) {
      adPlaybackState =
          expandAdGroupPlaceholder(
              adGroupIndex,
              /* adGroupDurationUs= */ msToUs(secToMsRounded(adPodInfo.getMaxDuration())),
              adIndexInAdGroup,
              /* adDurationUs= */ msToUs(secToMsRounded(ad.getDuration())),
              /* adsInAdGroupCount= */ adPodInfo.getTotalAds(),
              adPlaybackState);
    } else if (adIndexInAdGroup < adGroup.count - 1) {
      adPlaybackState =
          updateAdDurationInAdGroup(
              adGroupIndex,
              adIndexInAdGroup,
              /* adDurationUs= */ msToUs(secToMsRounded(ad.getDuration())),
              adPlaybackState);
    }
    return adPlaybackState;
  }

  private static AdPlaybackState skipAd(Ad ad, AdPlaybackState adPlaybackState) {
    AdPodInfo adPodInfo = ad.getAdPodInfo();
    int adGroupIndex = adPodInfo.getPodIndex();
    // IMA SDK always returns index starting at 1.
    int adIndexInAdGroup = adPodInfo.getAdPosition() - 1;
    return adPlaybackState.withSkippedAd(adGroupIndex, adIndexInAdGroup);
  }

  private final class ComponentListener
      implements AdEvent.AdEventListener, Player.Listener, AdPlaybackStateUpdater {

    private final AdEventListener adEventListener;

    /** Creates an new instance. */
    public ComponentListener(AdEventListener adEventListener) {
      this.adEventListener = adEventListener;
    }

    // Implement Player.Listener.

    @Override
    public void onPositionDiscontinuity(
        Player.PositionInfo oldPosition,
        Player.PositionInfo newPosition,
        @Player.DiscontinuityReason int reason) {
      if (!(reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
          || (isLiveStream && reason == Player.DISCONTINUITY_REASON_REMOVE))) {
        // Only auto transitions and removals of an ad period in live streams need to be handled.
        return;
      }

      MediaItem mediaItem = getMediaItem();
      if (mediaItem.equals(oldPosition.mediaItem) && !mediaItem.equals(newPosition.mediaItem)) {
        // Playback automatically transitioned to the next media item. Notify the SDK.
        streamPlayer.onContentCompleted();
      }

      if (!mediaItem.equals(oldPosition.mediaItem)
          || !mediaItem.equals(newPosition.mediaItem)
          || !adsId.equals(
              player
                  .getCurrentTimeline()
                  .getPeriodByUid(checkNotNull(newPosition.periodUid), new Timeline.Period())
                  .getAdsId())) {
        // Discontinuity not within this ad media source.
        return;
      }

      if (oldPosition.adGroupIndex != C.INDEX_UNSET) {
        int adGroupIndex = oldPosition.adGroupIndex;
        int adIndexInAdGroup = oldPosition.adIndexInAdGroup;
        Timeline timeline = player.getCurrentTimeline();
        Timeline.Window window =
            timeline.getWindow(oldPosition.mediaItemIndex, new Timeline.Window());
        if (window.lastPeriodIndex > window.firstPeriodIndex) {
          if (reason == Player.DISCONTINUITY_REASON_REMOVE) {
            setAdPlaybackState(
                handleAdPeriodRemovedFromTimeline(
                    player.getCurrentPeriodIndex(), timeline, adPlaybackState));
            return;
          }
          // Map adGroupIndex and adIndexInAdGroup to multi-period window.
          int periodIndexInContentTimeline = oldPosition.periodIndex - window.firstPeriodIndex;
          Pair<Integer, Integer> adGroupIndexAndAdIndexInAdGroup =
              window.isLive()
                  ? getAdGroupAndIndexInLiveMultiPeriodTimeline(
                      periodIndexInContentTimeline, adPlaybackState, checkNotNull(contentTimeline))
                  : getAdGroupAndIndexInVodMultiPeriodTimeline(
                      periodIndexInContentTimeline, adPlaybackState, checkNotNull(contentTimeline));
          adGroupIndex = adGroupIndexAndAdIndexInAdGroup.first;
          adIndexInAdGroup = adGroupIndexAndAdIndexInAdGroup.second;
        }

        AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex);
        int adState = adGroup.states[adIndexInAdGroup];
        if (adState == AD_STATE_AVAILABLE || adState == AD_STATE_UNAVAILABLE) {
          AdPlaybackState newAdPlaybackState =
              adPlaybackState.withPlayedAd(adGroupIndex, /* adIndexInAdGroup= */ adIndexInAdGroup);
          adGroup = newAdPlaybackState.getAdGroup(adGroupIndex);
          if (isLiveStream
              && newPosition.adGroupIndex == C.INDEX_UNSET
              && adIndexInAdGroup < adGroup.states.length - 1
              && adGroup.states[adIndexInAdGroup + 1] == AD_STATE_AVAILABLE) {
            // There is an available ad after the ad period that just ended being played!
            Log.w(TAG, "Detected late ad event. Regrouping trailing ads into separate ad group.");
            newAdPlaybackState =
                splitAdGroup(
                    adGroup,
                    adGroupIndex,
                    /* splitIndexExclusive= */ adIndexInAdGroup + 1,
                    newAdPlaybackState);
          }
          setAdPlaybackState(newAdPlaybackState);
        }
      }
    }

    @Override
    public void onMetadata(Metadata metadata) {
      if (!isCurrentlyPlayingMediaPeriodFromThisSource(player, getMediaItem(), adsId)) {
        return;
      }
      for (int i = 0; i < metadata.length(); i++) {
        Metadata.Entry entry = metadata.get(i);
        if (entry instanceof TextInformationFrame) {
          TextInformationFrame textFrame = (TextInformationFrame) entry;
          if ("TXXX".equals(textFrame.id)) {
            streamPlayer.triggerUserTextReceived(textFrame.values.get(0));
          }
        } else if (entry instanceof EventMessage) {
          EventMessage eventMessage = (EventMessage) entry;
          String eventMessageValue = new String(eventMessage.messageData);
          streamPlayer.triggerUserTextReceived(eventMessageValue);
        }
      }
    }

    @Override
    public void onPlaybackStateChanged(@Player.State int state) {
      if (state == Player.STATE_ENDED
          && isCurrentlyPlayingMediaPeriodFromThisSource(player, getMediaItem(), adsId)) {
        streamPlayer.onContentCompleted();
      }
    }

    @Override
    public void onVolumeChanged(float volume) {
      if (!isCurrentlyPlayingMediaPeriodFromThisSource(player, getMediaItem(), adsId)) {
        return;
      }
      int volumePct = (int) Math.floor(volume * 100);
      streamPlayer.onContentVolumeChanged(volumePct);
    }

    // Implement AdEvent.AdEventListener.

    @MainThread
    @Override
    public void onAdEvent(AdEvent event) {
      adEventListener.onAdEvent(event);
    }

    // Implement AdPlaybackStateUpdater (called on the playback thread).

    @Override
    public boolean onAdPlaybackStateUpdateRequested(Timeline contentTimeline) {
      mainHandler.post(() -> setContentTimeline(contentTimeline));
      // Defer source refresh to ad playback state update for VOD (wait for potential ad cue points)
      // or DASH (split manifest).
      return !isLiveStream || Objects.equals(streamRequest.getFormat(), StreamFormat.DASH);
    }
  }

  private final class StreamManagerLoadableCallback
      implements Loader.Callback<StreamManagerLoadable> {

    @Override
    public void onLoadCompleted(
        StreamManagerLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) {
      setContentUri(checkNotNull(loadable.getContentUri()));
    }

    @Override
    public void onLoadCanceled(
        StreamManagerLoadable loadable,
        long elapsedRealtimeMs,
        long loadDurationMs,
        boolean released) {
      // We only cancel when the loader is released.
      checkState(released);
    }

    @Override
    public LoadErrorAction onLoadError(
        StreamManagerLoadable loadable,
        long elapsedRealtimeMs,
        long loadDurationMs,
        IOException error,
        int errorCount) {
      loadError = error;
      return Loader.DONT_RETRY;
    }
  }

  /** Loads the {@link StreamManager} and the content URI. */
  private static class StreamManagerLoadable
      implements Loadable, AdsLoadedListener, AdErrorListener {

    private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader;
    private final ImaServerSideAdInsertionMediaSource imaServerSideAdInsertionMediaSource;
    private final StreamRequest request;
    private final StreamPlayer streamPlayer;
    @Nullable private final AdErrorListener adErrorListener;
    private final ConditionVariable conditionVariable;

    @Nullable private volatile Uri contentUri;
    private volatile boolean cancelled;
    private volatile boolean error;
    @Nullable private volatile String errorMessage;
    private volatile int errorCode;

    /** Creates an instance. */
    private StreamManagerLoadable(
        com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader,
        ImaServerSideAdInsertionMediaSource imaServerSideAdInsertionMediaSource,
        StreamRequest request,
        StreamPlayer streamPlayer,
        @Nullable AdErrorListener adErrorListener) {
      this.adsLoader = adsLoader;
      this.imaServerSideAdInsertionMediaSource = imaServerSideAdInsertionMediaSource;
      this.request = request;
      this.streamPlayer = streamPlayer;
      this.adErrorListener = adErrorListener;
      conditionVariable = new ConditionVariable();
      errorCode = -1;
    }

    /** Returns the DAI content URI or null if not yet available. */
    @Nullable
    public Uri getContentUri() {
      return contentUri;
    }

    // Implement Loadable.

    @Override
    public void load() throws IOException {
      try {
        // SDK will call loadUrl on stream player for SDK once manifest uri is available.
        streamPlayer.setStreamLoadListener(
            (streamUri, subtitles) -> {
              contentUri = Uri.parse(streamUri);
              conditionVariable.open();
            });
        if (adErrorListener != null) {
          adsLoader.addAdErrorListener(adErrorListener);
        }
        adsLoader.addAdsLoadedListener(this);
        adsLoader.addAdErrorListener(this);
        adsLoader.requestStream(request);
        while (contentUri == null && !cancelled && !error) {
          try {
            conditionVariable.block();
          } catch (InterruptedException e) {
            /* Do nothing. */
          }
        }
        if (error && contentUri == null) {
          throw new IOException(errorMessage + " [errorCode: " + errorCode + "]");
        }
      } finally {
        adsLoader.removeAdsLoadedListener(this);
        adsLoader.removeAdErrorListener(this);
        if (adErrorListener != null) {
          adsLoader.removeAdErrorListener(adErrorListener);
        }
      }
    }

    @Override
    public void cancelLoad() {
      cancelled = true;
    }

    // AdsLoader.AdsLoadedListener implementation.

    @MainThread
    @Override
    public void onAdsManagerLoaded(AdsManagerLoadedEvent event) {
      StreamManager streamManager = event.getStreamManager();
      if (streamManager == null) {
        error = true;
        errorMessage = "streamManager is null after ads manager has been loaded";
        conditionVariable.open();
        return;
      }
      imaServerSideAdInsertionMediaSource.setStreamManager(streamManager);
    }

    // AdErrorEvent.AdErrorListener implementation.

    @MainThread
    @Override
    public void onAdError(AdErrorEvent adErrorEvent) {
      error = true;
      if (adErrorEvent.getError() != null) {
        @Nullable String errorMessage = adErrorEvent.getError().getMessage();
        if (errorMessage != null) {
          this.errorMessage = errorMessage.replace('\n', ' ');
        }
        errorCode = adErrorEvent.getError().getErrorCodeNumber();
      }
      conditionVariable.open();
    }
  }

  /**
   * Receives the content URI from the SDK and sends back in-band media metadata and playback
   * progression data to the SDK.
   */
  private static final class StreamPlayer implements VideoStreamPlayer {

    /** A listener to listen for the stream URI loaded by the SDK. */
    public interface StreamLoadListener {
      /**
       * Loads a stream with dynamic ad insertion given the stream url and subtitles array. The
       * subtitles array is only used in VOD streams.
       *
       * <p>Each entry in the subtitles array is a HashMap that corresponds to a language. Each map
       * will have a "language" key with a two letter language string value, a "language name" to
       * specify the set of subtitles if multiple sets exist for the same language, and one or more
       * subtitle key/value pairs. Here's an example the map for English:
       *
       * <p>"language" -> "en" "language_name" -> "English" "webvtt" ->
       * "https://example.com/vtt/en.vtt" "ttml" -> "https://example.com/ttml/en.ttml"
       */
      void onLoadStream(String streamUri, List<HashMap<String, String>> subtitles);
    }

    private final List<VideoStreamPlayer.VideoStreamPlayerCallback> callbacks;
    private final Player player;
    private final MediaItem mediaItem;
    private final Timeline.Window window;
    private final Timeline.Period period;
    private final boolean isDashStream;

    private ImmutableMap<Object, AdPlaybackState> adPlaybackStates;
    @Nullable private Timeline contentTimeline;
    @Nullable private Object adsId;
    @Nullable private StreamLoadListener streamLoadListener;

    /** Creates an instance. */
    public StreamPlayer(Player player, MediaItem mediaItem, StreamRequest streamRequest) {
      this.player = player;
      this.mediaItem = mediaItem;
      this.isDashStream = streamRequest.getFormat() == StreamFormat.DASH;
      callbacks = new ArrayList<>(/* initialCapacity= */ 1);
      adPlaybackStates = ImmutableMap.of();
      window = new Timeline.Window();
      period = new Timeline.Period();
    }

    /** Registers the ad playback states matching to the given content timeline. */
    public void setAdPlaybackStates(
        Object adsId,
        ImmutableMap<Object, AdPlaybackState> adPlaybackStates,
        Timeline contentTimeline) {
      this.adsId = adsId;
      this.adPlaybackStates = adPlaybackStates;
      this.contentTimeline = contentTimeline;
    }

    /** Sets the {@link StreamLoadListener} to be called when the SSAI content URI was loaded. */
    public void setStreamLoadListener(StreamLoadListener listener) {
      streamLoadListener = Assertions.checkNotNull(listener);
    }

    /** Called when the content has completed playback. */
    public void onContentCompleted() {
      for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) {
        callback.onContentComplete();
      }
    }

    /** Called when the content player changed the volume. */
    public void onContentVolumeChanged(int volumePct) {
      for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) {
        callback.onVolumeChanged(volumePct);
      }
    }

    /** Releases the player. */
    public void release() {
      callbacks.clear();
      adsId = null;
      adPlaybackStates = ImmutableMap.of();
      contentTimeline = null;
      streamLoadListener = null;
    }

    // Implements VolumeProvider.

    @Override
    public int getVolume() {
      return (int) Math.floor(player.getVolume() * 100);
    }

    // Implement ContentProgressProvider.

    @Override
    public VideoProgressUpdate getContentProgress() {
      if (!isCurrentlyPlayingMediaPeriodFromThisSource(player, mediaItem, adsId)) {
        return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
      } else if (adPlaybackStates.isEmpty()) {
        return new VideoProgressUpdate(/* currentTimeMs= */ 0, /* durationMs= */ C.TIME_UNSET);
      }

      Timeline timeline = player.getCurrentTimeline();
      int currentPeriodIndex = player.getCurrentPeriodIndex();
      timeline.getPeriod(currentPeriodIndex, period, /* setIds= */ true);
      timeline.getWindow(player.getCurrentMediaItemIndex(), window);
      long streamPositionMs;
      if (isDashStream && window.isLive()) {
        // In multi-period live streams, we can't assume to find the same period in both timelines
        // with a given period index. Calculate stream position from the period structure instead.
        streamPositionMs =
            player.isPlayingAd()
                ? window.windowStartTimeMs
                    + usToMs(period.positionInWindowUs)
                    + player.getCurrentPosition()
                : window.windowStartTimeMs + player.getContentPosition();
      } else {
        // The map of ad playback states is keyed with the period UID of the content timeline. In
        // timelines that do not change the periods (VOD and single period live), we can use the
        // period index in both timelines.
        Timeline.Period contentPeriod =
            checkNotNull(contentTimeline)
                .getPeriod(
                    currentPeriodIndex - window.firstPeriodIndex,
                    new Timeline.Period(),
                    /* setIds= */ true);
        AdPlaybackState adPlaybackState = checkNotNull(adPlaybackStates.get(contentPeriod.uid));
        // Calculate the stream position from the current position and the playback state.
        streamPositionMs =
            usToMs(ServerSideAdInsertionUtil.getStreamPositionUs(player, adPlaybackState));
        if (window.windowStartTimeMs != C.TIME_UNSET) {
          // Add the time since epoch at start of the window for live streams.
          streamPositionMs += window.windowStartTimeMs + period.getPositionInWindowMs();
        } else if (currentPeriodIndex > window.firstPeriodIndex) {
          // Add the end position of the previous period in the underlying stream.
          checkNotNull(contentTimeline)
              .getPeriod(
                  currentPeriodIndex - window.firstPeriodIndex - 1,
                  contentPeriod,
                  /* setIds= */ true);
          streamPositionMs += usToMs(contentPeriod.positionInWindowUs + contentPeriod.durationUs);
        }
      }
      return new VideoProgressUpdate(
          streamPositionMs,
          checkNotNull(contentTimeline).getWindow(/* windowIndex= */ 0, window).getDurationMs());
    }

    // Implement VideoStreamPlayer.

    @Override
    public void loadUrl(String url, List<HashMap<String, String>> subtitles) {
      if (streamLoadListener != null) {
        // SDK provided manifest url, notify the listener.
        streamLoadListener.onLoadStream(url, subtitles);
      }
    }

    @Override
    public void addCallback(VideoStreamPlayer.VideoStreamPlayerCallback callback) {
      callbacks.add(callback);
    }

    @Override
    public void removeCallback(VideoStreamPlayer.VideoStreamPlayerCallback callback) {
      callbacks.remove(callback);
    }

    @Override
    public void onAdBreakStarted() {
      // Do nothing.
    }

    @Override
    public void onAdBreakEnded() {
      // Do nothing.
    }

    @Override
    public void onAdPeriodStarted() {
      // Do nothing.
    }

    @Override
    public void onAdPeriodEnded() {
      // Do nothing.
    }

    @Override
    public void pause() {
      // Do nothing.
    }

    @Override
    public void resume() {
      // Do nothing.
    }

    @Override
    public void seek(long timeMs) {
      // Do nothing.
    }

    // Internal methods.

    private void triggerUserTextReceived(String userText) {
      for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) {
        callback.onUserTextReceived(userText);
      }
    }
  }

  private static boolean isCurrentlyPlayingMediaPeriodFromThisSource(
      Player player, MediaItem mediaItem, @Nullable Object adsId) {
    if (player.getPlaybackState() == Player.STATE_IDLE || player.getMediaItemCount() == 0) {
      return false;
    }
    Timeline.Period period = new Timeline.Period();
    player.getCurrentTimeline().getPeriod(player.getCurrentPeriodIndex(), period);
    return (period.isPlaceholder && mediaItem.equals(player.getCurrentMediaItem()))
        || (adsId != null && adsId.equals(period.getAdsId()));
  }

  private static StreamDisplayContainer createStreamDisplayContainer(
      ImaSdkFactory imaSdkFactory,
      ServerSideAdInsertionConfiguration config,
      StreamPlayer streamPlayer) {
    StreamDisplayContainer container =
        ImaSdkFactory.createStreamDisplayContainer(
            checkNotNull(config.adViewProvider.getAdViewGroup()), streamPlayer);
    container.setCompanionSlots(config.companionAdSlots);
    registerFriendlyObstructions(imaSdkFactory, container, config.adViewProvider);
    return container;
  }

  private static void registerFriendlyObstructions(
      ImaSdkFactory imaSdkFactory,
      StreamDisplayContainer container,
      AdViewProvider adViewProvider) {
    for (int i = 0; i < adViewProvider.getAdOverlayInfos().size(); i++) {
      AdOverlayInfo overlayInfo = adViewProvider.getAdOverlayInfos().get(i);
      container.registerFriendlyObstruction(
          imaSdkFactory.createFriendlyObstruction(
              overlayInfo.view,
              ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose),
              overlayInfo.reasonDetail != null ? overlayInfo.reasonDetail : "Unknown reason"));
    }
  }

  private static void assertSingleInstanceInPlaylist(Player player) {
    int counter = 0;
    for (int i = 0; i < player.getMediaItemCount(); i++) {
      MediaItem mediaItem = player.getMediaItemAt(i);
      if (mediaItem.localConfiguration != null
          && C.SSAI_SCHEME.equals(mediaItem.localConfiguration.uri.getScheme())
          && ImaServerSideAdInsertionUriBuilder.IMA_AUTHORITY.equals(
              mediaItem.localConfiguration.uri.getAuthority())) {
        if (++counter > 1) {
          throw new IllegalStateException(
              "Multiple IMA server side ad insertion sources not supported.");
        }
      }
    }
  }

  private class VodAdEventListener implements AdEventListener {
    @Override
    public void onAdEvent(AdEvent event) {
      AdPlaybackState newAdPlaybackState = adPlaybackState;
      switch (event.getType()) {
        case CUEPOINTS_CHANGED:
          if (newAdPlaybackState.equals(AdPlaybackState.NONE)) {
            newAdPlaybackState =
                setVodAdGroupPlaceholders(
                    checkNotNull(streamManager).getCuePoints(), new AdPlaybackState(adsId));
          }
          break;
        case LOADED:
          newAdPlaybackState = setVodAdInPlaceholder(event.getAd(), newAdPlaybackState);
          break;
        case SKIPPED:
          newAdPlaybackState = skipAd(event.getAd(), newAdPlaybackState);
          break;
        default:
          // Do nothing.
          break;
      }
      setAdPlaybackState(newAdPlaybackState);
    }
  }

  private class SinglePeriodLiveAdEventListener implements AdEventListener {
    @Override
    public void onAdEvent(AdEvent event) {
      if (!Objects.equals(event.getType(), LOADED)
          || !isCurrentlyPlayingMediaPeriodFromThisSource(player, getMediaItem(), adsId)) {
        return;
      }
      AdPlaybackState newAdPlaybackState = adPlaybackState;
      Timeline timeline = player.getCurrentTimeline();
      Timeline.Period currentPeriod = new Timeline.Period();
      long positionInWindowUs =
          timeline.getPeriod(player.getCurrentPeriodIndex(), currentPeriod).positionInWindowUs;
      long contentPositionUs =
          player.isPlayingAd()
              ? currentPeriod.getAdGroupTimeUs(player.getCurrentAdGroupIndex())
              : msToUs(player.getContentPosition());
      Ad ad = event.getAd();
      AdPodInfo adPodInfo = ad.getAdPodInfo();
      newAdPlaybackState =
          addLiveAdBreak(
              /* currentContentPeriodPositionUs= */ contentPositionUs - positionInWindowUs,
              /* adDurationUs= */ secToUsRounded(ad.getDuration()),
              /* adPositionInAdPod= */ adPodInfo.getAdPosition(),
              /* totalAdDurationUs= */ secToUsRounded(adPodInfo.getMaxDuration()),
              /* totalAdsInAdPod= */ adPodInfo.getTotalAds(),
              /* adPlaybackState= */ newAdPlaybackState.equals(AdPlaybackState.NONE)
                  ? new AdPlaybackState(adsId)
                  : newAdPlaybackState);
      setAdPlaybackState(newAdPlaybackState);
    }
  }

  private class MultiPeriodLiveAdEventListener implements AdEventListener {
    @Override
    public void onAdEvent(AdEvent event) {
      if (!Objects.equals(event.getType(), LOADED)
          || !isCurrentlyPlayingMediaPeriodFromThisSource(player, getMediaItem(), adsId)) {
        return;
      }
      AdPodInfo adPodInfo = event.getAd().getAdPodInfo();
      Timeline timeline = player.getCurrentTimeline();
      Timeline.Window window = new Timeline.Window();
      Timeline.Period adPeriod = new Timeline.Period();
      // In case all periods are in the live window, we need to correct the ad group duration when
      // inserting the first ad. Try calculate ad group duration from media structure.
      long totalAdDurationUs =
          getAdGroupDurationUsForLiveAdPeriodIndex(
              timeline,
              adPodInfo,
              /* adPeriodIndex= */ player.getCurrentPeriodIndex(),
              window,
              adPeriod);
      long adPeriodStartTimeUs =
          getWindowStartTimeUs(window.windowStartTimeMs, window.positionInFirstPeriodUs)
              + adPeriod.positionInWindowUs;
      long adDurationUs =
          adPeriod.durationUs != C.TIME_UNSET
              ? adPeriod.durationUs
              : secToUsRounded(event.getAd().getDuration());
      setAdPlaybackState(
          addLiveAdBreak(
              /* currentContentPeriodPositionUs= */ adPeriodStartTimeUs,
              adDurationUs,
              adPodInfo.getAdPosition(),
              totalAdDurationUs,
              adPodInfo.getTotalAds(),
              adPlaybackState));
    }
  }
}