ImaAdsLoader.java

/*
 * Copyright (C) 2017 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.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.exoplayer.ima.ImaUtil.BITRATE_UNSET;
import static androidx.media3.exoplayer.ima.ImaUtil.TIMEOUT_UNSET;
import static androidx.media3.exoplayer.ima.ImaUtil.getImaLooper;

import android.content.Context;
import android.os.Looper;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.AdViewProvider;
import androidx.media3.common.C;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSpec;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.ads.AdsLoader;
import androidx.media3.exoplayer.source.ads.AdsMediaSource;
import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener;
import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener;
import com.google.ads.interactivemedia.v3.api.AdsManager;
import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
import com.google.ads.interactivemedia.v3.api.AdsRequest;
import com.google.ads.interactivemedia.v3.api.CompanionAdSlot;
import com.google.ads.interactivemedia.v3.api.FriendlyObstruction;
import com.google.ads.interactivemedia.v3.api.FriendlyObstructionPurpose;
import com.google.ads.interactivemedia.v3.api.ImaSdkFactory;
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
import com.google.ads.interactivemedia.v3.api.UiElement;
import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Set;

/**
 * {@link AdsLoader} using the IMA SDK. All methods must be called on the main thread.
 *
 * <p>The player instance that will play the loaded ads must be set before playback using {@link
 * #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling
 * {@link #release()}.
 *
 * <p>See <a
 * href="https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility">IMA's
 * Support and compatibility page</a> for information on compatible ad tag formats. Pass the ad tag
 * URI when setting media item playback properties (if using the media item API) or as a {@link
 * DataSpec} when constructing the {@link AdsMediaSource} (if using media sources directly). For the
 * latter case, please note that this implementation delegates loading of the data spec to the IMA
 * SDK, so range and headers specifications will be ignored in ad tag URIs. Literal ads responses
 * can be encoded as data scheme data specs, for example, by constructing the data spec using a URI
 * generated via {@link Util#getDataUriForString(String, String)}.
 *
 * <p>The IMA SDK can report obstructions to the ad view for accurate viewability measurement. This
 * means that any overlay views that obstruct the ad overlay but are essential for playback need to
 * be registered via the {@link AdViewProvider} passed to the {@link AdsMediaSource}. See the <a
 * href="https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/omsdk">IMA
 * SDK Open Measurement documentation</a> for more information.
 */
public final class ImaAdsLoader implements AdsLoader {

  static {
    MediaLibraryInfo.registerModule("media3.exoplayer.ima");
  }

  /** Builder for {@link ImaAdsLoader}. */
  public static final class Builder {

    /**
     * The default duration in milliseconds for which the player must buffer while preloading an ad
     * group before that ad group is skipped and marked as having failed to load.
     *
     * <p>This value should be large enough not to trigger discarding the ad when it actually might
     * load soon, but small enough so that user is not waiting for too long.
     *
     * @see #setAdPreloadTimeoutMs(long)
     */
    @UnstableApi public static final long DEFAULT_AD_PRELOAD_TIMEOUT_MS = 10 * C.MILLIS_PER_SECOND;

    private final Context context;

    @Nullable private ImaSdkSettings imaSdkSettings;
    @Nullable private AdErrorListener adErrorListener;
    @Nullable private AdEventListener adEventListener;
    @Nullable private VideoAdPlayer.VideoAdPlayerCallback videoAdPlayerCallback;
    @Nullable private List<String> adMediaMimeTypes;
    @Nullable private Set<UiElement> adUiElements;
    @Nullable private Collection<CompanionAdSlot> companionAdSlots;
    @Nullable private Boolean enableContinuousPlayback;
    private long adPreloadTimeoutMs;
    private int vastLoadTimeoutMs;
    private int mediaLoadTimeoutMs;
    private int mediaBitrate;
    private boolean focusSkipButtonWhenAvailable;
    private boolean playAdBeforeStartPosition;
    private boolean debugModeEnabled;
    private ImaUtil.ImaFactory imaFactory;

    /**
     * Creates a new builder for {@link ImaAdsLoader}.
     *
     * @param context The context;
     */
    public Builder(Context context) {
      this.context = checkNotNull(context).getApplicationContext();
      adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS;
      vastLoadTimeoutMs = TIMEOUT_UNSET;
      mediaLoadTimeoutMs = TIMEOUT_UNSET;
      mediaBitrate = BITRATE_UNSET;
      focusSkipButtonWhenAvailable = true;
      playAdBeforeStartPosition = true;
      imaFactory = new DefaultImaFactory();
    }

    /**
     * Sets the IMA SDK settings. The provided settings instance's player type and version fields
     * may be overwritten.
     *
     * <p>If this method is not called the default settings will be used.
     *
     * @param imaSdkSettings The {@link ImaSdkSettings}.
     * @return This builder, for convenience.
     */
    @UnstableApi
    public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
      this.imaSdkSettings = checkNotNull(imaSdkSettings);
      return this;
    }

    /**
     * Sets a listener for ad errors that will be passed to {@link
     * com.google.ads.interactivemedia.v3.api.AdsLoader#addAdErrorListener(AdErrorListener)} and
     * {@link AdsManager#addAdErrorListener(AdErrorListener)}.
     *
     * @param adErrorListener The ad error listener.
     * @return This builder, for convenience.
     */
    @UnstableApi
    public Builder setAdErrorListener(AdErrorListener adErrorListener) {
      this.adErrorListener = checkNotNull(adErrorListener);
      return this;
    }

    /**
     * Sets a listener for ad events that will be passed to {@link
     * AdsManager#addAdEventListener(AdEventListener)}.
     *
     * @param adEventListener The ad event listener.
     * @return This builder, for convenience.
     */
    @UnstableApi
    public Builder setAdEventListener(AdEventListener adEventListener) {
      this.adEventListener = checkNotNull(adEventListener);
      return this;
    }

    /**
     * Sets a callback to receive video ad player events. Note that these events are handled
     * internally by the IMA SDK and this ads loader. For analytics and diagnostics, new
     * implementations should generally use events from the top-level {@link Player} listeners
     * instead of setting a callback via this method.
     *
     * @param videoAdPlayerCallback The callback to receive video ad player events.
     * @return This builder, for convenience.
     * @see com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer.VideoAdPlayerCallback
     */
    @UnstableApi
    public Builder setVideoAdPlayerCallback(
        VideoAdPlayer.VideoAdPlayerCallback videoAdPlayerCallback) {
      this.videoAdPlayerCallback = checkNotNull(videoAdPlayerCallback);
      return this;
    }

    /**
     * Sets the ad UI elements to be rendered by the IMA SDK.
     *
     * @param adUiElements The ad UI elements to be rendered by the IMA SDK.
     * @return This builder, for convenience.
     * @see AdsRenderingSettings#setUiElements(Set)
     */
    @UnstableApi
    public Builder setAdUiElements(Set<UiElement> adUiElements) {
      this.adUiElements = ImmutableSet.copyOf(checkNotNull(adUiElements));
      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
    public Builder setCompanionAdSlots(Collection<CompanionAdSlot> companionAdSlots) {
      this.companionAdSlots = ImmutableList.copyOf(checkNotNull(companionAdSlots));
      return this;
    }

    /**
     * Sets the MIME types to prioritize for linear ad media. If not specified, MIME types supported
     * by the {@link MediaSource.Factory adMediaSourceFactory} used to construct the {@link
     * AdsMediaSource} will be used.
     *
     * @param adMediaMimeTypes The MIME types to prioritize for linear ad media. May contain {@link
     *     MimeTypes#APPLICATION_MPD}, {@link MimeTypes#APPLICATION_M3U8}, {@link
     *     MimeTypes#VIDEO_MP4}, {@link MimeTypes#VIDEO_WEBM}, {@link MimeTypes#VIDEO_H263}, {@link
     *     MimeTypes#AUDIO_MP4} and {@link MimeTypes#AUDIO_MPEG}.
     * @return This builder, for convenience.
     * @see AdsRenderingSettings#setMimeTypes(List)
     */
    @UnstableApi
    public Builder setAdMediaMimeTypes(List<String> adMediaMimeTypes) {
      this.adMediaMimeTypes = ImmutableList.copyOf(checkNotNull(adMediaMimeTypes));
      return this;
    }

    /**
     * Sets whether to enable continuous playback. Pass {@code true} if content videos will be
     * played continuously, similar to a TV broadcast. This setting may modify the ads request but
     * does not affect ad playback behavior. The requested value is unknown by default.
     *
     * @param enableContinuousPlayback Whether to enable continuous playback.
     * @return This builder, for convenience.
     * @see AdsRequest#setContinuousPlayback(boolean)
     */
    @UnstableApi
    public Builder setEnableContinuousPlayback(boolean enableContinuousPlayback) {
      this.enableContinuousPlayback = enableContinuousPlayback;
      return this;
    }

    /**
     * Sets the duration in milliseconds for which the player must buffer while preloading an ad
     * group before that ad group is skipped and marked as having failed to load. Pass {@link
     * C#TIME_UNSET} if there should be no such timeout. The default value is {@value
     * #DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms.
     *
     * <p>The purpose of this timeout is to avoid playback getting stuck in the unexpected case that
     * the IMA SDK does not load an ad break based on the player's reported content position.
     *
     * @param adPreloadTimeoutMs The timeout buffering duration in milliseconds, or {@link
     *     C#TIME_UNSET} for no timeout.
     * @return This builder, for convenience.
     */
    @UnstableApi
    public Builder setAdPreloadTimeoutMs(long adPreloadTimeoutMs) {
      checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0);
      this.adPreloadTimeoutMs = adPreloadTimeoutMs;
      return this;
    }

    /**
     * Sets the VAST load timeout, in milliseconds.
     *
     * @param vastLoadTimeoutMs The VAST load timeout, in milliseconds.
     * @return This builder, for convenience.
     * @see AdsRequest#setVastLoadTimeout(float)
     */
    @UnstableApi
    public Builder setVastLoadTimeoutMs(@IntRange(from = 1) int vastLoadTimeoutMs) {
      checkArgument(vastLoadTimeoutMs > 0);
      this.vastLoadTimeoutMs = vastLoadTimeoutMs;
      return this;
    }

    /**
     * Sets the ad media load timeout, in milliseconds.
     *
     * @param mediaLoadTimeoutMs The ad media load timeout, in milliseconds.
     * @return This builder, for convenience.
     * @see AdsRenderingSettings#setLoadVideoTimeout(int)
     */
    @UnstableApi
    public Builder setMediaLoadTimeoutMs(@IntRange(from = 1) int mediaLoadTimeoutMs) {
      checkArgument(mediaLoadTimeoutMs > 0);
      this.mediaLoadTimeoutMs = mediaLoadTimeoutMs;
      return this;
    }

    /**
     * Sets the media maximum recommended bitrate for ads, in bps.
     *
     * @param bitrate The media maximum recommended bitrate for ads, in bps.
     * @return This builder, for convenience.
     * @see AdsRenderingSettings#setBitrateKbps(int)
     */
    @UnstableApi
    public Builder setMaxMediaBitrate(@IntRange(from = 1) int bitrate) {
      checkArgument(bitrate > 0);
      this.mediaBitrate = bitrate;
      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
    public Builder setFocusSkipButtonWhenAvailable(boolean focusSkipButtonWhenAvailable) {
      this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable;
      return this;
    }

    /**
     * Sets whether to play an ad before the start position when beginning playback. If {@code
     * true}, an ad will be played if there is one at or before the start position. If {@code
     * false}, an ad will be played only if there is one exactly at the start position. The default
     * setting is {@code true}.
     *
     * @param playAdBeforeStartPosition Whether to play an ad before the start position when
     *     beginning playback.
     * @return This builder, for convenience.
     */
    @UnstableApi
    public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) {
      this.playAdBeforeStartPosition = playAdBeforeStartPosition;
      return this;
    }

    /**
     * Sets whether to enable outputting verbose logs for the IMA extension and IMA SDK. The default
     * value is {@code false}. This setting is intended for debugging only, and should not be
     * enabled in production applications.
     *
     * @param debugModeEnabled Whether to enable outputting verbose logs for the IMA extension and
     *     IMA SDK.
     * @return This builder, for convenience.
     * @see ImaSdkSettings#setDebugMode(boolean)
     */
    @UnstableApi
    public Builder setDebugModeEnabled(boolean debugModeEnabled) {
      this.debugModeEnabled = debugModeEnabled;
      return this;
    }

    @VisibleForTesting
    /* package */ Builder setImaFactory(ImaUtil.ImaFactory imaFactory) {
      this.imaFactory = checkNotNull(imaFactory);
      return this;
    }

    /** Returns a new {@link ImaAdsLoader}. */
    public ImaAdsLoader build() {
      return new ImaAdsLoader(
          context,
          new ImaUtil.Configuration(
              adPreloadTimeoutMs,
              vastLoadTimeoutMs,
              mediaLoadTimeoutMs,
              focusSkipButtonWhenAvailable,
              playAdBeforeStartPosition,
              mediaBitrate,
              enableContinuousPlayback,
              adMediaMimeTypes,
              adUiElements,
              companionAdSlots,
              adErrorListener,
              adEventListener,
              videoAdPlayerCallback,
              imaSdkSettings,
              debugModeEnabled),
          imaFactory);
    }
  }

  private final ImaUtil.Configuration configuration;
  private final Context context;
  private final ImaUtil.ImaFactory imaFactory;
  private final PlayerListenerImpl playerListener;
  private final HashMap<Object, AdTagLoader> adTagLoaderByAdsId;
  private final HashMap<AdsMediaSource, AdTagLoader> adTagLoaderByAdsMediaSource;
  private final Timeline.Period period;
  private final Timeline.Window window;

  private boolean wasSetPlayerCalled;
  @Nullable private Player nextPlayer;
  private List<String> supportedMimeTypes;
  @Nullable private Player player;
  @Nullable private AdTagLoader currentAdTagLoader;

  private ImaAdsLoader(
      Context context, ImaUtil.Configuration configuration, ImaUtil.ImaFactory imaFactory) {
    this.context = context.getApplicationContext();
    this.configuration = configuration;
    this.imaFactory = imaFactory;
    playerListener = new PlayerListenerImpl();
    supportedMimeTypes = ImmutableList.of();
    adTagLoaderByAdsId = new HashMap<>();
    adTagLoaderByAdsMediaSource = new HashMap<>();
    period = new Timeline.Period();
    window = new Timeline.Window();
  }

  /**
   * Returns the underlying {@link com.google.ads.interactivemedia.v3.api.AdsLoader} wrapped by this
   * instance, or {@code null} if ads have not been requested yet.
   */
  @UnstableApi
  @Nullable
  public com.google.ads.interactivemedia.v3.api.AdsLoader getAdsLoader() {
    return currentAdTagLoader != null ? currentAdTagLoader.getAdsLoader() : null;
  }

  /**
   * Returns the {@link AdDisplayContainer} used by this loader, or {@code null} if ads have not
   * been requested yet.
   *
   * <p>Note: any video controls overlays registered via {@link
   * AdDisplayContainer#registerFriendlyObstruction(FriendlyObstruction)} will be unregistered
   * automatically when the media source detaches from this instance. It is therefore necessary to
   * re-register views each time the ads loader is reused. Alternatively, provide overlay views via
   * the {@link AdViewProvider} when creating the media source to benefit from automatic
   * registration.
   */
  @UnstableApi
  @Nullable
  public AdDisplayContainer getAdDisplayContainer() {
    return currentAdTagLoader != null ? currentAdTagLoader.getAdDisplayContainer() : null;
  }

  /**
   * Requests ads, if they have not already been requested. Must be called on the main thread.
   *
   * <p>Ads will be requested automatically when the player is prepared if this method has not been
   * called, so it is only necessary to call this method if you want to request ads before preparing
   * the player.
   *
   * @param adTagDataSpec The data specification of the ad tag to load. See class javadoc for
   *     information about compatible ad tag formats.
   * @param adsId A opaque identifier for the ad playback state across start/stop calls.
   * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code
   *     null} if playing audio-only ads.
   */
  @UnstableApi
  public void requestAds(DataSpec adTagDataSpec, Object adsId, @Nullable ViewGroup adViewGroup) {
    if (!adTagLoaderByAdsId.containsKey(adsId)) {
      AdTagLoader adTagLoader =
          new AdTagLoader(
              context,
              configuration,
              imaFactory,
              supportedMimeTypes,
              adTagDataSpec,
              adsId,
              adViewGroup);
      adTagLoaderByAdsId.put(adsId, adTagLoader);
    }
  }

  /**
   * Skips the current ad.
   *
   * <p>This method is intended for apps that play audio-only ads and so need to provide their own
   * UI for users to skip skippable ads. Apps showing video ads should not call this method, as the
   * IMA SDK provides the UI to skip ads in the ad view group passed via {@link AdViewProvider}.
   */
  @UnstableApi
  public void skipAd() {
    if (currentAdTagLoader != null) {
      currentAdTagLoader.skipAd();
    }
  }

  /**
   * Moves UI focus to the skip button (or other interactive elements), if currently shown. See
   * {@link AdsManager#focus()}.
   */
  @UnstableApi
  public void focusSkipButton() {
    if (currentAdTagLoader != null) {
      currentAdTagLoader.focusSkipButton();
    }
  }

  // AdsLoader implementation.

  @Override
  public void setPlayer(@Nullable Player player) {
    checkState(Looper.myLooper() == getImaLooper());
    checkState(player == null || player.getApplicationLooper() == getImaLooper());
    nextPlayer = player;
    wasSetPlayerCalled = true;
  }

  @UnstableApi
  @Override
  public void setSupportedContentTypes(@C.ContentType int... contentTypes) {
    List<String> supportedMimeTypes = new ArrayList<>();
    for (@C.ContentType int contentType : contentTypes) {
      // IMA does not support Smooth Streaming ad media.
      if (contentType == C.TYPE_DASH) {
        supportedMimeTypes.add(MimeTypes.APPLICATION_MPD);
      } else if (contentType == C.TYPE_HLS) {
        supportedMimeTypes.add(MimeTypes.APPLICATION_M3U8);
      } else if (contentType == C.TYPE_OTHER) {
        supportedMimeTypes.addAll(
            Arrays.asList(
                MimeTypes.VIDEO_MP4,
                MimeTypes.VIDEO_WEBM,
                MimeTypes.VIDEO_H263,
                MimeTypes.AUDIO_MP4,
                MimeTypes.AUDIO_MPEG));
      }
    }
    this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes);
  }

  @UnstableApi
  @Override
  public void start(
      AdsMediaSource adsMediaSource,
      DataSpec adTagDataSpec,
      Object adsId,
      AdViewProvider adViewProvider,
      EventListener eventListener) {
    checkState(
        wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player.");
    if (adTagLoaderByAdsMediaSource.isEmpty()) {
      player = nextPlayer;
      @Nullable Player player = this.player;
      if (player == null) {
        return;
      }
      player.addListener(playerListener);
    }

    @Nullable AdTagLoader adTagLoader = adTagLoaderByAdsId.get(adsId);
    if (adTagLoader == null) {
      requestAds(adTagDataSpec, adsId, adViewProvider.getAdViewGroup());
      adTagLoader = adTagLoaderByAdsId.get(adsId);
    }
    adTagLoaderByAdsMediaSource.put(adsMediaSource, checkNotNull(adTagLoader));
    adTagLoader.addListenerWithAdView(eventListener, adViewProvider);
    maybeUpdateCurrentAdTagLoader();
  }

  @UnstableApi
  @Override
  public void stop(AdsMediaSource adsMediaSource, EventListener eventListener) {
    @Nullable AdTagLoader removedAdTagLoader = adTagLoaderByAdsMediaSource.remove(adsMediaSource);
    maybeUpdateCurrentAdTagLoader();
    if (removedAdTagLoader != null) {
      removedAdTagLoader.removeListener(eventListener);
    }

    if (player != null && adTagLoaderByAdsMediaSource.isEmpty()) {
      player.removeListener(playerListener);
      player = null;
    }
  }

  @Override
  public void release() {
    if (player != null) {
      player.removeListener(playerListener);
      player = null;
      maybeUpdateCurrentAdTagLoader();
    }
    nextPlayer = null;

    for (AdTagLoader adTagLoader : adTagLoaderByAdsMediaSource.values()) {
      adTagLoader.release();
    }
    adTagLoaderByAdsMediaSource.clear();

    for (AdTagLoader adTagLoader : adTagLoaderByAdsId.values()) {
      adTagLoader.release();
    }
    adTagLoaderByAdsId.clear();
  }

  @UnstableApi
  @Override
  public void handlePrepareComplete(
      AdsMediaSource adsMediaSource, int adGroupIndex, int adIndexInAdGroup) {
    if (player == null) {
      return;
    }
    checkNotNull(adTagLoaderByAdsMediaSource.get(adsMediaSource))
        .handlePrepareComplete(adGroupIndex, adIndexInAdGroup);
  }

  @UnstableApi
  @Override
  public void handlePrepareError(
      AdsMediaSource adsMediaSource,
      int adGroupIndex,
      int adIndexInAdGroup,
      IOException exception) {
    if (player == null) {
      return;
    }
    checkNotNull(adTagLoaderByAdsMediaSource.get(adsMediaSource))
        .handlePrepareError(adGroupIndex, adIndexInAdGroup, exception);
  }

  // Internal methods.

  private void maybeUpdateCurrentAdTagLoader() {
    @Nullable AdTagLoader oldAdTagLoader = currentAdTagLoader;
    @Nullable AdTagLoader newAdTagLoader = getCurrentAdTagLoader();
    if (!Util.areEqual(oldAdTagLoader, newAdTagLoader)) {
      if (oldAdTagLoader != null) {
        oldAdTagLoader.deactivate();
      }
      currentAdTagLoader = newAdTagLoader;
      if (newAdTagLoader != null) {
        newAdTagLoader.activate(checkNotNull(player));
      }
    }
  }

  @Nullable
  private AdTagLoader getCurrentAdTagLoader() {
    @Nullable Player player = this.player;
    if (player == null) {
      return null;
    }
    Timeline timeline = player.getCurrentTimeline();
    if (timeline.isEmpty()) {
      return null;
    }
    int periodIndex = player.getCurrentPeriodIndex();
    @Nullable Object adsId = timeline.getPeriod(periodIndex, period).getAdsId();
    if (adsId == null) {
      return null;
    }
    @Nullable AdTagLoader adTagLoader = adTagLoaderByAdsId.get(adsId);
    if (adTagLoader == null || !adTagLoaderByAdsMediaSource.containsValue(adTagLoader)) {
      return null;
    }
    return adTagLoader;
  }

  private void maybePreloadNextPeriodAds() {
    @Nullable Player player = ImaAdsLoader.this.player;
    if (player == null) {
      return;
    }
    Timeline timeline = player.getCurrentTimeline();
    if (timeline.isEmpty()) {
      return;
    }
    int nextPeriodIndex =
        timeline.getNextPeriodIndex(
            player.getCurrentPeriodIndex(),
            period,
            window,
            player.getRepeatMode(),
            player.getShuffleModeEnabled());
    if (nextPeriodIndex == C.INDEX_UNSET) {
      return;
    }
    timeline.getPeriod(nextPeriodIndex, period);
    @Nullable Object nextAdsId = period.getAdsId();
    if (nextAdsId == null) {
      return;
    }
    @Nullable AdTagLoader nextAdTagLoader = adTagLoaderByAdsId.get(nextAdsId);
    if (nextAdTagLoader == null || nextAdTagLoader == currentAdTagLoader) {
      return;
    }
    long periodPositionUs =
        timeline.getPeriodPositionUs(
                window, period, period.windowIndex, /* windowPositionUs= */ C.TIME_UNSET)
            .second;
    nextAdTagLoader.maybePreloadAds(Util.usToMs(periodPositionUs), Util.usToMs(period.durationUs));
  }

  private final class PlayerListenerImpl implements Player.Listener {

    @Override
    public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
      if (timeline.isEmpty()) {
        // The player is being reset or contains no media.
        return;
      }
      maybeUpdateCurrentAdTagLoader();
      maybePreloadNextPeriodAds();
    }

    @Override
    public void onPositionDiscontinuity(
        Player.PositionInfo oldPosition,
        Player.PositionInfo newPosition,
        @Player.DiscontinuityReason int reason) {
      maybeUpdateCurrentAdTagLoader();
      maybePreloadNextPeriodAds();
    }

    @Override
    public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {
      maybePreloadNextPeriodAds();
    }

    @Override
    public void onRepeatModeChanged(@Player.RepeatMode int repeatMode) {
      maybePreloadNextPeriodAds();
    }
  }

  /**
   * Default {@link ImaUtil.ImaFactory} for non-test usage, which delegates to {@link
   * ImaSdkFactory}.
   */
  private static final class DefaultImaFactory implements ImaUtil.ImaFactory {
    @Override
    public ImaSdkSettings createImaSdkSettings() {
      ImaSdkSettings settings = ImaSdkFactory.getInstance().createImaSdkSettings();
      settings.setLanguage(Util.getSystemLanguageCodes()[0]);
      return settings;
    }

    @Override
    public AdsRenderingSettings createAdsRenderingSettings() {
      return ImaSdkFactory.getInstance().createAdsRenderingSettings();
    }

    @Override
    public AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player) {
      return ImaSdkFactory.createAdDisplayContainer(container, player);
    }

    @Override
    public AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player) {
      return ImaSdkFactory.createAudioAdDisplayContainer(context, player);
    }

    // The reasonDetail parameter to createFriendlyObstruction is annotated @Nullable but the
    // annotation is not kept in the obfuscated dependency.
    @SuppressWarnings("nullness:argument")
    @Override
    public FriendlyObstruction createFriendlyObstruction(
        View view,
        FriendlyObstructionPurpose friendlyObstructionPurpose,
        @Nullable String reasonDetail) {
      return ImaSdkFactory.getInstance()
          .createFriendlyObstruction(view, friendlyObstructionPurpose, reasonDetail);
    }

    @Override
    public AdsRequest createAdsRequest() {
      return ImaSdkFactory.getInstance().createAdsRequest();
    }

    @Override
    public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader(
        Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) {
      return ImaSdkFactory.getInstance()
          .createAdsLoader(context, imaSdkSettings, adDisplayContainer);
    }
  }
}