Commit 32b71071 by andrewlewis Committed by Oliver Woodman

Split AdTagLoader out of ImaAdsLoader

In a later change ImaAdsLoader will use multiple AdTagLoaders.

This change shouldn't have any substantial changes in behavior (it's almost
entirely moving code around). An exception is that ImaSdkSettings is configured
when making a request rather than at construction time.

Issue: #3750
PiperOrigin-RevId: 339891712
parent 27707e9c
/*
* Copyright 2020 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 com.google.android.exoplayer2.ext.ima;
import static com.google.android.exoplayer2.ext.ima.ImaUtil.BITRATE_UNSET;
import static com.google.android.exoplayer2.ext.ima.ImaUtil.TIMEOUT_UNSET;
import static com.google.android.exoplayer2.ext.ima.ImaUtil.getImaLooper;
import static com.google.android.exoplayer2.util.Assertions.checkArgument;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Assertions.checkState;
import static java.lang.Math.max;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.SystemClock;
import android.view.ViewGroup;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
import com.google.ads.interactivemedia.v3.api.AdError;
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.AdEvent.AdEventType;
import com.google.ads.interactivemedia.v3.api.AdPodInfo;
import com.google.ads.interactivemedia.v3.api.AdsLoader;
import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener;
import com.google.ads.interactivemedia.v3.api.AdsManager;
import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
import com.google.ads.interactivemedia.v3.api.AdsRequest;
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo;
import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider;
import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer;
import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.ads.AdPlaybackState;
import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider;
import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener;
import com.google.android.exoplayer2.source.ads.AdsLoader.OverlayInfo;
import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Handles loading and playback of a single ad tag. */
/* package */ final class AdTagLoader implements Player.EventListener {
private static final String TAG = "AdTagLoader";
private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima";
private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION;
/**
* Interval at which ad progress updates are provided to the IMA SDK, in milliseconds. 100 ms is
* the interval recommended by the IMA documentation.
*
* @see VideoAdPlayer.VideoAdPlayerCallback
*/
private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100;
/** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */
private static final long IMA_DURATION_UNSET = -1L;
/**
* Threshold before the end of content at which IMA is notified that content is complete if the
* player buffers, in milliseconds.
*/
private static final long THRESHOLD_END_OF_CONTENT_MS = 5000;
/**
* Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in
* milliseconds.
*/
private static final long THRESHOLD_AD_PRELOAD_MS = 4000;
/** The threshold below which ad cue points are treated as matching, in microseconds. */
private static final long THRESHOLD_AD_MATCH_US = 1000;
/** The state of ad playback. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED})
private @interface ImaAdState {}
/** The ad playback state when IMA is not playing an ad. */
private static final int IMA_AD_STATE_NONE = 0;
/**
* The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not
* {@link ComponentListener##pauseAd(AdMediaInfo)}.
*/
private static final int IMA_AD_STATE_PLAYING = 1;
/**
* The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while
* playing an ad.
*/
private static final int IMA_AD_STATE_PAUSED = 2;
private final ImaUtil.Configuration configuration;
private final ImaUtil.ImaFactory imaFactory;
private final List<String> supportedMimeTypes;
private final DataSpec adTagDataSpec;
private final Timeline.Period period;
private final Handler handler;
private final ComponentListener componentListener;
private final List<VideoAdPlayer.VideoAdPlayerCallback> adCallbacks;
private final Runnable updateAdProgressRunnable;
private final BiMap<AdMediaInfo, AdInfo> adInfoByAdMediaInfo;
private final AdDisplayContainer adDisplayContainer;
private final AdsLoader adsLoader;
@Nullable private Object pendingAdRequestContext;
@Nullable private EventListener eventListener;
@Nullable private Player player;
private VideoProgressUpdate lastContentProgress;
private VideoProgressUpdate lastAdProgress;
private int lastVolumePercent;
@Nullable private AdsManager adsManager;
private boolean isAdsManagerInitialized;
private boolean hasAdPlaybackState;
@Nullable private AdLoadException pendingAdLoadError;
private Timeline timeline;
private long contentDurationMs;
private AdPlaybackState adPlaybackState;
// Fields tracking IMA's state.
/** Whether IMA has sent an ad event to pause content since the last resume content event. */
private boolean imaPausedContent;
/** The current ad playback state. */
private @ImaAdState int imaAdState;
/** The current ad media info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */
@Nullable private AdMediaInfo imaAdMediaInfo;
/** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */
@Nullable private AdInfo imaAdInfo;
/** Whether IMA has been notified that playback of content has finished. */
private boolean sentContentComplete;
// Fields tracking the player/loader state.
/** Whether the player is playing an ad. */
private boolean playingAd;
/** Whether the player is buffering an ad. */
private boolean bufferingAd;
/**
* If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET}
* otherwise.
*/
private int playingAdIndexInAdGroup;
/**
* The ad info for a pending ad for which the media failed preparation, or {@code null} if no
* pending ads have failed to prepare.
*/
@Nullable private AdInfo pendingAdPrepareErrorAdInfo;
/**
* If a content period has finished but IMA has not yet called {@link
* ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link
* SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine
* a fake, increasing content position. {@link C#TIME_UNSET} otherwise.
*/
private long fakeContentProgressElapsedRealtimeMs;
/**
* If {@link #fakeContentProgressElapsedRealtimeMs} is set, stores the offset from which the
* content progress should increase. {@link C#TIME_UNSET} otherwise.
*/
private long fakeContentProgressOffsetMs;
/** Stores the pending content position when a seek operation was intercepted to play an ad. */
private long pendingContentPositionMs;
/**
* Whether {@link ComponentListener#getContentProgress()} has sent {@link
* #pendingContentPositionMs} to IMA.
*/
private boolean sentPendingContentPositionMs;
/**
* Stores the real time in milliseconds at which the player started buffering, possibly due to not
* having preloaded an ad, or {@link C#TIME_UNSET} if not applicable.
*/
private long waitingForPreloadElapsedRealtimeMs;
/** Creates a new ad tag loader, starting the ad request if the ad tag is valid. */
@SuppressWarnings({"methodref.receiver.bound.invalid", "method.invocation.invalid"})
public AdTagLoader(
Context context,
ImaUtil.Configuration configuration,
ImaUtil.ImaFactory imaFactory,
List<String> supportedMimeTypes,
DataSpec adTagDataSpec,
@Nullable ViewGroup adViewGroup) {
this.configuration = configuration;
this.imaFactory = imaFactory;
@Nullable ImaSdkSettings imaSdkSettings = configuration.imaSdkSettings;
if (imaSdkSettings == null) {
imaSdkSettings = imaFactory.createImaSdkSettings();
if (configuration.debugModeEnabled) {
imaSdkSettings.setDebugMode(true);
}
}
imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE);
imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION);
this.supportedMimeTypes = supportedMimeTypes;
this.adTagDataSpec = adTagDataSpec;
period = new Timeline.Period();
handler = Util.createHandler(getImaLooper(), /* callback= */ null);
componentListener = new ComponentListener();
adCallbacks = new ArrayList<>(/* initialCapacity= */ 1);
if (configuration.applicationVideoAdPlayerCallback != null) {
adCallbacks.add(configuration.applicationVideoAdPlayerCallback);
}
updateAdProgressRunnable = this::updateAdProgress;
adInfoByAdMediaInfo = HashBiMap.create();
lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
fakeContentProgressOffsetMs = C.TIME_UNSET;
pendingContentPositionMs = C.TIME_UNSET;
waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
contentDurationMs = C.TIME_UNSET;
timeline = Timeline.EMPTY;
adPlaybackState = AdPlaybackState.NONE;
if (adViewGroup != null) {
adDisplayContainer =
imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener);
} else {
adDisplayContainer =
imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener);
}
if (configuration.companionAdSlots != null) {
adDisplayContainer.setCompanionSlots(configuration.companionAdSlots);
}
adsLoader = requestAds(context, imaSdkSettings, adDisplayContainer);
}
/** Returns the underlying IMA SDK ads loader. */
public AdsLoader getAdsLoader() {
return adsLoader;
}
/** Returns the IMA SDK ad display container. */
public AdDisplayContainer getAdDisplayContainer() {
return adDisplayContainer;
}
/** Skips the current skippable ad, if there is one. */
public void skipAd() {
if (adsManager != null) {
adsManager.skip();
}
}
/** Starts using the ads loader for playback. */
public void start(Player player, AdViewProvider adViewProvider, EventListener eventListener) {
this.player = player;
player.addListener(this);
boolean playWhenReady = player.getPlayWhenReady();
this.eventListener = eventListener;
lastVolumePercent = 0;
lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
maybeNotifyPendingAdLoadError();
if (hasAdPlaybackState) {
// Pass the ad playback state to the player, and resume ads if necessary.
eventListener.onAdPlaybackState(adPlaybackState);
if (adsManager != null && imaPausedContent && playWhenReady) {
adsManager.resume();
}
} else if (adsManager != null) {
adPlaybackState = ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints());
updateAdPlaybackState();
}
if (adDisplayContainer != null) {
for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) {
adDisplayContainer.registerFriendlyObstruction(
imaFactory.createFriendlyObstruction(
overlayInfo.view,
ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose),
overlayInfo.reasonDetail));
}
}
}
/** Stops using the ads loader for playback. */
public void stop() {
@Nullable Player player = this.player;
if (player == null) {
return;
}
if (adsManager != null && imaPausedContent) {
adsManager.pause();
adPlaybackState =
adPlaybackState.withAdResumePositionUs(
playingAd ? C.msToUs(player.getCurrentPosition()) : 0);
}
lastVolumePercent = getPlayerVolumePercent();
lastAdProgress = getAdVideoProgressUpdate();
lastContentProgress = getContentVideoProgressUpdate();
if (adDisplayContainer != null) {
adDisplayContainer.unregisterAllFriendlyObstructions();
}
player.removeListener(this);
this.player = null;
eventListener = null;
}
/** Releases all resources used by the ad tag loader. */
public void release() {
pendingAdRequestContext = null;
destroyAdsManager();
if (adsLoader != null) {
adsLoader.removeAdsLoadedListener(componentListener);
adsLoader.removeAdErrorListener(componentListener);
if (configuration.applicationAdErrorListener != null) {
adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener);
}
adsLoader.release();
}
imaPausedContent = false;
imaAdState = IMA_AD_STATE_NONE;
imaAdMediaInfo = null;
stopUpdatingAdProgress();
imaAdInfo = null;
pendingAdLoadError = null;
adPlaybackState = AdPlaybackState.NONE;
hasAdPlaybackState = true;
updateAdPlaybackState();
}
/** Notifies the IMA SDK that the specified ad has been prepared for playback. */
public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) {
AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
if (configuration.debugModeEnabled) {
Log.d(TAG, "Prepared ad " + adInfo);
}
@Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo);
if (adMediaInfo != null) {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onLoaded(adMediaInfo);
}
} else {
Log.w(TAG, "Unexpected prepared ad " + adInfo);
}
}
/** Notifies the IMA SDK that the specified ad has failed to prepare for playback. */
public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {
if (player == null) {
return;
}
try {
handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception);
} catch (RuntimeException e) {
maybeNotifyInternalError("handlePrepareError", e);
}
}
// Player.EventListener implementation.
@Override
public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
if (timeline.isEmpty()) {
// The player is being reset or contains no media.
return;
}
checkArgument(timeline.getPeriodCount() == 1);
this.timeline = timeline;
long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs;
contentDurationMs = C.usToMs(contentDurationUs);
if (contentDurationUs != C.TIME_UNSET) {
adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs);
}
@Nullable AdsManager adsManager = this.adsManager;
if (!isAdsManagerInitialized && adsManager != null) {
isAdsManagerInitialized = true;
@Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering();
if (adsRenderingSettings == null) {
// There are no ads to play.
destroyAdsManager();
} else {
adsManager.init(adsRenderingSettings);
adsManager.start();
if (configuration.debugModeEnabled) {
Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings);
}
}
updateAdPlaybackState();
}
handleTimelineOrPositionChanged();
}
@Override
public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
handleTimelineOrPositionChanged();
}
@Override
public void onPlaybackStateChanged(@Player.State int playbackState) {
@Nullable Player player = this.player;
if (adsManager == null || player == null) {
return;
}
if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) {
// Check whether we are waiting for an ad to preload.
int adGroupIndex = getLoadingAdGroupIndex();
if (adGroupIndex == C.INDEX_UNSET) {
return;
}
AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
if (adGroup.count != C.LENGTH_UNSET
&& adGroup.count != 0
&& adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) {
// An ad is available already so we must be buffering for some other reason.
return;
}
long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
long contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
long timeUntilAdMs = adGroupTimeMs - contentPositionMs;
if (timeUntilAdMs < configuration.adPreloadTimeoutMs) {
waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime();
}
} else if (playbackState == Player.STATE_READY) {
waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
}
handlePlayerStateChanged(player.getPlayWhenReady(), playbackState);
}
@Override
public void onPlayWhenReadyChanged(
boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
if (adsManager == null || player == null) {
return;
}
if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) {
adsManager.pause();
return;
}
if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) {
adsManager.resume();
return;
}
handlePlayerStateChanged(playWhenReady, player.getPlaybackState());
}
@Override
public void onPlayerError(ExoPlaybackException error) {
if (imaAdState != IMA_AD_STATE_NONE) {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onError(adMediaInfo);
}
}
}
// Internal methods.
private AdsLoader requestAds(
Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) {
AdsLoader adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer);
adsLoader.addAdErrorListener(componentListener);
if (configuration.applicationAdErrorListener != null) {
adsLoader.addAdErrorListener(configuration.applicationAdErrorListener);
}
adsLoader.addAdsLoadedListener(componentListener);
AdsRequest request;
try {
request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec);
} catch (IOException e) {
hasAdPlaybackState = true;
updateAdPlaybackState();
pendingAdLoadError = AdLoadException.createForAllAds(e);
maybeNotifyPendingAdLoadError();
return adsLoader;
}
pendingAdRequestContext = new Object();
request.setUserRequestContext(pendingAdRequestContext);
if (configuration.vastLoadTimeoutMs != TIMEOUT_UNSET) {
request.setVastLoadTimeout(configuration.vastLoadTimeoutMs);
}
request.setContentProgressProvider(componentListener);
adsLoader.requestAds(request);
return adsLoader;
}
/**
* Configures ads rendering for starting playback, returning the settings for the IMA SDK or
* {@code null} if no ads should play.
*/
@Nullable
private AdsRenderingSettings setupAdsRendering() {
AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings();
adsRenderingSettings.setEnablePreloading(true);
adsRenderingSettings.setMimeTypes(
configuration.adMediaMimeTypes != null
? configuration.adMediaMimeTypes
: supportedMimeTypes);
if (configuration.mediaLoadTimeoutMs != TIMEOUT_UNSET) {
adsRenderingSettings.setLoadVideoTimeout(configuration.mediaLoadTimeoutMs);
}
if (configuration.mediaBitrate != BITRATE_UNSET) {
adsRenderingSettings.setBitrateKbps(configuration.mediaBitrate / 1000);
}
adsRenderingSettings.setFocusSkipButtonWhenAvailable(
configuration.focusSkipButtonWhenAvailable);
if (configuration.adUiElements != null) {
adsRenderingSettings.setUiElements(configuration.adUiElements);
}
// Skip ads based on the start position as required.
long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs;
long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period);
int adGroupForPositionIndex =
adPlaybackState.getAdGroupIndexForPositionUs(
C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
if (adGroupForPositionIndex != C.INDEX_UNSET) {
boolean playAdWhenStartingPlayback =
configuration.playAdBeforeStartPosition
|| adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs);
if (!playAdWhenStartingPlayback) {
adGroupForPositionIndex++;
} else if (hasMidrollAdGroups(adGroupTimesUs)) {
// Provide the player's initial position to trigger loading and playing the ad. If there are
// no midrolls, we are playing a preroll and any pending content position wouldn't be
// cleared.
pendingContentPositionMs = contentPositionMs;
}
if (adGroupForPositionIndex > 0) {
for (int i = 0; i < adGroupForPositionIndex; i++) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
}
if (adGroupForPositionIndex == adGroupTimesUs.length) {
// We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP
// ads, we signal that no ads will render so the caller can destroy the ads manager.
return null;
}
long adGroupForPositionTimeUs = adGroupTimesUs[adGroupForPositionIndex];
long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1];
if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) {
// Play the postroll by offsetting the start position just past the last non-postroll ad.
adsRenderingSettings.setPlayAdsAfterTime(
(double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d);
} else {
// Play ads after the midpoint between the ad to play and the one before it, to avoid
// issues with rounding one of the two ad times.
double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d;
adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND);
}
}
}
return adsRenderingSettings;
}
private VideoProgressUpdate getContentVideoProgressUpdate() {
if (player == null) {
return lastContentProgress;
}
boolean hasContentDuration = contentDurationMs != C.TIME_UNSET;
long contentPositionMs;
if (pendingContentPositionMs != C.TIME_UNSET) {
sentPendingContentPositionMs = true;
contentPositionMs = pendingContentPositionMs;
} else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) {
long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs;
contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs;
} else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) {
contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
} else {
return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
}
long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET;
return new VideoProgressUpdate(contentPositionMs, contentDurationMs);
}
private VideoProgressUpdate getAdVideoProgressUpdate() {
if (player == null) {
return lastAdProgress;
} else if (imaAdState != IMA_AD_STATE_NONE && playingAd) {
long adDuration = player.getDuration();
return adDuration == C.TIME_UNSET
? VideoProgressUpdate.VIDEO_TIME_NOT_READY
: new VideoProgressUpdate(player.getCurrentPosition(), adDuration);
} else {
return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
}
}
private void updateAdProgress() {
VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate();
if (configuration.debugModeEnabled) {
Log.d(TAG, "Ad progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate));
}
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate);
}
handler.removeCallbacks(updateAdProgressRunnable);
handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS);
}
private void stopUpdatingAdProgress() {
handler.removeCallbacks(updateAdProgressRunnable);
}
private int getPlayerVolumePercent() {
@Nullable Player player = this.player;
if (player == null) {
return lastVolumePercent;
}
@Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
if (audioComponent != null) {
return (int) (audioComponent.getVolume() * 100);
}
// Check for a selected track using an audio renderer.
TrackSelectionArray trackSelections = player.getCurrentTrackSelections();
for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) {
if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) {
return 100;
}
}
return 0;
}
private void handleAdEvent(AdEvent adEvent) {
if (adsManager == null) {
// Drop events after release.
return;
}
switch (adEvent.getType()) {
case AD_BREAK_FETCH_ERROR:
String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime"));
if (configuration.debugModeEnabled) {
Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds");
}
double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString);
int adGroupIndex =
adGroupTimeSeconds == -1.0
? adPlaybackState.adGroupCount - 1
: getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds);
markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex);
break;
case CONTENT_PAUSE_REQUESTED:
// After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads
// before sending CONTENT_RESUME_REQUESTED.
imaPausedContent = true;
pauseContentInternal();
break;
case TAPPED:
if (eventListener != null) {
eventListener.onAdTapped();
}
break;
case CLICKED:
if (eventListener != null) {
eventListener.onAdClicked();
}
break;
case CONTENT_RESUME_REQUESTED:
imaPausedContent = false;
resumeContentInternal();
break;
case LOG:
Map<String, String> adData = adEvent.getAdData();
String message = "AdEvent: " + adData;
Log.i(TAG, message);
break;
default:
break;
}
}
private void pauseContentInternal() {
imaAdState = IMA_AD_STATE_NONE;
if (sentPendingContentPositionMs) {
pendingContentPositionMs = C.TIME_UNSET;
sentPendingContentPositionMs = false;
}
}
private void resumeContentInternal() {
if (imaAdInfo != null) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex);
updateAdPlaybackState();
} else {
// Mark any ads for the current/reported player position that haven't loaded as being in the
// error state, to force resuming content. This includes VPAID ads that never load.
long playerPositionUs;
if (player != null) {
playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period));
} else if (!VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(lastContentProgress)) {
// Playback is backgrounded so use the last reported content position.
playerPositionUs = C.msToUs(lastContentProgress.getCurrentTimeMs());
} else {
return;
}
int adGroupIndex =
adPlaybackState.getAdGroupIndexForPositionUs(
playerPositionUs, C.msToUs(contentDurationMs));
if (adGroupIndex != C.INDEX_UNSET) {
markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex);
}
}
}
private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) {
if (!bufferingAd && playbackState == Player.STATE_BUFFERING) {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onBuffering(adMediaInfo);
}
stopUpdatingAdProgress();
} else if (bufferingAd && playbackState == Player.STATE_READY) {
bufferingAd = false;
updateAdProgress();
}
}
if (imaAdState == IMA_AD_STATE_NONE
&& playbackState == Player.STATE_BUFFERING
&& playWhenReady) {
ensureSentContentCompleteIfAtEndOfStream();
} else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
if (adMediaInfo == null) {
Log.w(TAG, "onEnded without ad media info");
} else {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onEnded(adMediaInfo);
}
}
if (configuration.debugModeEnabled) {
Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged");
}
}
}
private void handleTimelineOrPositionChanged() {
@Nullable Player player = this.player;
if (adsManager == null || player == null) {
return;
}
if (!playingAd && !player.isPlayingAd()) {
ensureSentContentCompleteIfAtEndOfStream();
if (!sentContentComplete && !timeline.isEmpty()) {
long positionMs = getContentPeriodPositionMs(player, timeline, period);
timeline.getPeriod(/* periodIndex= */ 0, period);
int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs));
if (newAdGroupIndex != C.INDEX_UNSET) {
sentPendingContentPositionMs = false;
pendingContentPositionMs = positionMs;
}
}
}
boolean wasPlayingAd = playingAd;
int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup;
playingAd = player.isPlayingAd();
playingAdIndexInAdGroup = playingAd ? player.getCurrentAdIndexInAdGroup() : C.INDEX_UNSET;
boolean adFinished = wasPlayingAd && playingAdIndexInAdGroup != oldPlayingAdIndexInAdGroup;
if (adFinished) {
// IMA is waiting for the ad playback to finish so invoke the callback now.
// Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again.
@Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo;
if (adMediaInfo == null) {
Log.w(TAG, "onEnded without ad media info");
} else {
@Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
if (playingAdIndexInAdGroup == C.INDEX_UNSET
|| (adInfo != null && adInfo.adIndexInAdGroup < playingAdIndexInAdGroup)) {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onEnded(adMediaInfo);
}
if (configuration.debugModeEnabled) {
Log.d(
TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity");
}
}
}
}
if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) {
int adGroupIndex = player.getCurrentAdGroupIndex();
if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) {
sendContentComplete();
} else {
// IMA hasn't called playAd yet, so fake the content position.
fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
fakeContentProgressOffsetMs = contentDurationMs;
}
}
}
}
private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
if (adsManager == null) {
// Drop events after release.
if (configuration.debugModeEnabled) {
Log.d(
TAG,
"loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo);
}
return;
}
int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo);
int adIndexInAdGroup = adPodInfo.getAdPosition() - 1;
AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
adInfoByAdMediaInfo.put(adMediaInfo, adInfo);
if (configuration.debugModeEnabled) {
Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo));
}
if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) {
// We have already marked this ad as having failed to load, so ignore the request. IMA will
// timeout after its media load timeout.
return;
}
// The ad count may increase on successive loads of ads in the same ad pod, for example, due to
// separate requests for ad tags with multiple ads within the ad pod completing after an earlier
// ad has loaded. See also https://github.com/google/ExoPlayer/issues/7477.
AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex];
adPlaybackState =
adPlaybackState.withAdCount(
adInfo.adGroupIndex, max(adPodInfo.getTotalAds(), adGroup.states.length));
adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex];
for (int i = 0; i < adIndexInAdGroup; i++) {
// Any preceding ads that haven't loaded are not going to load.
if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) {
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, /* adIndexInAdGroup= */ i);
}
}
Uri adUri = Uri.parse(adMediaInfo.getUrl());
adPlaybackState =
adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri);
updateAdPlaybackState();
}
private void playAdInternal(AdMediaInfo adMediaInfo) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo));
}
if (adsManager == null) {
// Drop events after release.
return;
}
if (imaAdState == IMA_AD_STATE_PLAYING) {
// IMA does not always call stopAd before resuming content.
// See [Internal: b/38354028].
Log.w(TAG, "Unexpected playAd without stopAd");
}
if (imaAdState == IMA_AD_STATE_NONE) {
// IMA is requesting to play the ad, so stop faking the content position.
fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
fakeContentProgressOffsetMs = C.TIME_UNSET;
imaAdState = IMA_AD_STATE_PLAYING;
imaAdMediaInfo = adMediaInfo;
imaAdInfo = checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo));
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onPlay(adMediaInfo);
}
if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) {
pendingAdPrepareErrorAdInfo = null;
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onError(adMediaInfo);
}
}
updateAdProgress();
} else {
imaAdState = IMA_AD_STATE_PLAYING;
checkState(adMediaInfo.equals(imaAdMediaInfo));
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onResume(adMediaInfo);
}
}
if (!checkNotNull(player).getPlayWhenReady()) {
checkNotNull(adsManager).pause();
}
}
private void pauseAdInternal(AdMediaInfo adMediaInfo) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo));
}
if (adsManager == null) {
// Drop event after release.
return;
}
if (imaAdState == IMA_AD_STATE_NONE) {
// This method is called if loadAd has been called but the loaded ad won't play due to a seek
// to a different position, so drop the event. See also [Internal: b/159111848].
return;
}
checkState(adMediaInfo.equals(imaAdMediaInfo));
imaAdState = IMA_AD_STATE_PAUSED;
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onPause(adMediaInfo);
}
}
private void stopAdInternal(AdMediaInfo adMediaInfo) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo));
}
if (adsManager == null) {
// Drop event after release.
return;
}
if (imaAdState == IMA_AD_STATE_NONE) {
// This method is called if loadAd has been called but the preloaded ad won't play due to a
// seek to a different position, so drop the event and discard the ad. See also [Internal:
// b/159111848].
@Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
if (adInfo != null) {
adPlaybackState =
adPlaybackState.withSkippedAd(adInfo.adGroupIndex, adInfo.adIndexInAdGroup);
updateAdPlaybackState();
}
return;
}
checkNotNull(player);
imaAdState = IMA_AD_STATE_NONE;
stopUpdatingAdProgress();
// TODO: Handle the skipped event so the ad can be marked as skipped rather than played.
checkNotNull(imaAdInfo);
int adGroupIndex = imaAdInfo.adGroupIndex;
int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup;
if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) {
// We have already marked this ad as having failed to load, so ignore the request.
return;
}
adPlaybackState =
adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0);
updateAdPlaybackState();
if (!playingAd) {
imaAdMediaInfo = null;
imaAdInfo = null;
}
}
private void handleAdGroupLoadError(Exception error) {
int adGroupIndex = getLoadingAdGroupIndex();
if (adGroupIndex == C.INDEX_UNSET) {
Log.w(TAG, "Unable to determine ad group index for ad group load error", error);
return;
}
markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex);
if (pendingAdLoadError == null) {
pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex);
}
}
private void markAdGroupInErrorStateAndClearPendingContentPosition(int adGroupIndex) {
// Update the ad playback state so all ads in the ad group are in the error state.
AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
if (adGroup.count == C.LENGTH_UNSET) {
adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length));
adGroup = adPlaybackState.adGroups[adGroupIndex];
}
for (int i = 0; i < adGroup.count; i++) {
if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex);
}
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i);
}
}
updateAdPlaybackState();
// Clear any pending content position that triggered attempting to load the ad group.
pendingContentPositionMs = C.TIME_UNSET;
fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
}
private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Exception exception) {
if (configuration.debugModeEnabled) {
Log.d(
TAG, "Prepare error for ad " + adIndexInAdGroup + " in group " + adGroupIndex, exception);
}
if (adsManager == null) {
Log.w(TAG, "Ignoring ad prepare error after release");
return;
}
if (imaAdState == IMA_AD_STATE_NONE) {
// Send IMA a content position at the ad group so that it will try to play it, at which point
// we can notify that it failed to load.
fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
fakeContentProgressOffsetMs = contentDurationMs;
}
pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
} else {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
// We're already playing an ad.
if (adIndexInAdGroup > playingAdIndexInAdGroup) {
// Mark the playing ad as ended so we can notify the error on the next ad and remove it,
// which means that the ad after will load (if any).
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onEnded(adMediaInfo);
}
}
playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay();
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onError(checkNotNull(adMediaInfo));
}
}
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup);
updateAdPlaybackState();
}
private void ensureSentContentCompleteIfAtEndOfStream() {
if (!sentContentComplete
&& contentDurationMs != C.TIME_UNSET
&& pendingContentPositionMs == C.TIME_UNSET
&& getContentPeriodPositionMs(checkNotNull(player), timeline, period)
+ THRESHOLD_END_OF_CONTENT_MS
>= contentDurationMs) {
sendContentComplete();
}
}
private void sendContentComplete() {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onContentComplete();
}
sentContentComplete = true;
if (configuration.debugModeEnabled) {
Log.d(TAG, "adsLoader.contentComplete");
}
for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i);
}
}
updateAdPlaybackState();
}
private void updateAdPlaybackState() {
// Ignore updates while detached. When a player is attached it will receive the latest state.
if (eventListener != null) {
eventListener.onAdPlaybackState(adPlaybackState);
}
}
private void maybeNotifyPendingAdLoadError() {
if (pendingAdLoadError != null && eventListener != null) {
eventListener.onAdLoadError(pendingAdLoadError, adTagDataSpec);
pendingAdLoadError = null;
}
}
private void maybeNotifyInternalError(String name, Exception cause) {
String message = "Internal error in " + name;
Log.e(TAG, message, cause);
// We can't recover from an unexpected error in general, so skip all remaining ads.
for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
}
updateAdPlaybackState();
if (eventListener != null) {
eventListener.onAdLoadError(
AdLoadException.createForUnexpected(new RuntimeException(message, cause)), adTagDataSpec);
}
}
private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) {
if (adPodInfo.getPodIndex() == -1) {
// This is a postroll ad.
return adPlaybackState.adGroupCount - 1;
}
// adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead.
return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset());
}
/**
* Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is
* no such ad group.
*/
private int getLoadingAdGroupIndex() {
if (player == null) {
return C.INDEX_UNSET;
}
long playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period));
int adGroupIndex =
adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs));
if (adGroupIndex == C.INDEX_UNSET) {
adGroupIndex =
adPlaybackState.getAdGroupIndexAfterPositionUs(
playerPositionUs, C.msToUs(contentDurationMs));
}
return adGroupIndex;
}
private int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) {
// We receive initial cue points from IMA SDK as floats. This code replicates the same
// calculation used to populate adGroupTimesUs (having truncated input back to float, to avoid
// failures if the behavior of the IMA SDK changes to provide greater precision).
float cuePointTimeSecondsFloat = (float) cuePointTimeSeconds;
long adPodTimeUs = Math.round((double) cuePointTimeSecondsFloat * C.MICROS_PER_SECOND);
for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex];
if (adGroupTimeUs != C.TIME_END_OF_SOURCE
&& Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) {
return adGroupIndex;
}
}
throw new IllegalStateException("Failed to find cue point");
}
private String getAdMediaInfoString(AdMediaInfo adMediaInfo) {
@Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]";
}
private static long getContentPeriodPositionMs(
Player player, Timeline timeline, Timeline.Period period) {
long contentWindowPositionMs = player.getContentPosition();
return contentWindowPositionMs
- (timeline.isEmpty()
? 0
: timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs());
}
private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) {
int count = adGroupTimesUs.length;
if (count == 1) {
return adGroupTimesUs[0] != 0 && adGroupTimesUs[0] != C.TIME_END_OF_SOURCE;
} else if (count == 2) {
return adGroupTimesUs[0] != 0 || adGroupTimesUs[1] != C.TIME_END_OF_SOURCE;
} else {
// There's at least one midroll ad group, as adGroupTimesUs is never empty.
return true;
}
}
private void destroyAdsManager() {
if (adsManager != null) {
adsManager.removeAdErrorListener(componentListener);
if (configuration.applicationAdErrorListener != null) {
adsManager.removeAdErrorListener(configuration.applicationAdErrorListener);
}
adsManager.removeAdEventListener(componentListener);
if (configuration.applicationAdEventListener != null) {
adsManager.removeAdEventListener(configuration.applicationAdEventListener);
}
adsManager.destroy();
adsManager = null;
}
}
private final class ComponentListener
implements AdsLoadedListener,
ContentProgressProvider,
AdEventListener,
AdErrorListener,
VideoAdPlayer {
// AdsLoader.AdsLoadedListener implementation.
@Override
public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) {
AdsManager adsManager = adsManagerLoadedEvent.getAdsManager();
if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) {
adsManager.destroy();
return;
}
pendingAdRequestContext = null;
AdTagLoader.this.adsManager = adsManager;
adsManager.addAdErrorListener(this);
if (configuration.applicationAdErrorListener != null) {
adsManager.addAdErrorListener(configuration.applicationAdErrorListener);
}
adsManager.addAdEventListener(this);
if (configuration.applicationAdEventListener != null) {
adsManager.addAdEventListener(configuration.applicationAdEventListener);
}
if (player != null) {
// If a player is attached already, start playback immediately.
try {
adPlaybackState =
ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints());
hasAdPlaybackState = true;
updateAdPlaybackState();
} catch (RuntimeException e) {
maybeNotifyInternalError("onAdsManagerLoaded", e);
}
}
}
// ContentProgressProvider implementation.
@Override
public VideoProgressUpdate getContentProgress() {
VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate();
if (configuration.debugModeEnabled) {
Log.d(
TAG,
"Content progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate));
}
if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) {
// IMA is polling the player position but we are buffering for an ad to preload, so playback
// may be stuck. Detect this case and signal an error if applicable.
long stuckElapsedRealtimeMs =
SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs;
if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) {
waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
handleAdGroupLoadError(new IOException("Ad preloading timed out"));
maybeNotifyPendingAdLoadError();
}
}
return videoProgressUpdate;
}
// AdEvent.AdEventListener implementation.
@Override
public void onAdEvent(AdEvent adEvent) {
AdEventType adEventType = adEvent.getType();
if (configuration.debugModeEnabled && adEventType != AdEventType.AD_PROGRESS) {
Log.d(TAG, "onAdEvent: " + adEventType);
}
try {
handleAdEvent(adEvent);
} catch (RuntimeException e) {
maybeNotifyInternalError("onAdEvent", e);
}
}
// AdErrorEvent.AdErrorListener implementation.
@Override
public void onAdError(AdErrorEvent adErrorEvent) {
AdError error = adErrorEvent.getError();
if (configuration.debugModeEnabled) {
Log.d(TAG, "onAdError", error);
}
if (adsManager == null) {
// No ads were loaded, so allow playback to start without any ads.
pendingAdRequestContext = null;
adPlaybackState = AdPlaybackState.NONE;
hasAdPlaybackState = true;
updateAdPlaybackState();
} else if (ImaUtil.isAdGroupLoadError(error)) {
try {
handleAdGroupLoadError(error);
} catch (RuntimeException e) {
maybeNotifyInternalError("onAdError", e);
}
}
if (pendingAdLoadError == null) {
pendingAdLoadError = AdLoadException.createForAllAds(error);
}
maybeNotifyPendingAdLoadError();
}
// VideoAdPlayer implementation.
@Override
public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
adCallbacks.add(videoAdPlayerCallback);
}
@Override
public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
adCallbacks.remove(videoAdPlayerCallback);
}
@Override
public VideoProgressUpdate getAdProgress() {
throw new IllegalStateException("Unexpected call to getAdProgress when using preloading");
}
@Override
public int getVolume() {
return getPlayerVolumePercent();
}
@Override
public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
try {
loadAdInternal(adMediaInfo, adPodInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("loadAd", e);
}
}
@Override
public void playAd(AdMediaInfo adMediaInfo) {
try {
playAdInternal(adMediaInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("playAd", e);
}
}
@Override
public void pauseAd(AdMediaInfo adMediaInfo) {
try {
pauseAdInternal(adMediaInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("pauseAd", e);
}
}
@Override
public void stopAd(AdMediaInfo adMediaInfo) {
try {
stopAdInternal(adMediaInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("stopAd", e);
}
}
@Override
public void release() {
// Do nothing.
}
}
// TODO: Consider moving this into AdPlaybackState.
private static final class AdInfo {
public final int adGroupIndex;
public final int adIndexInAdGroup;
public AdInfo(int adGroupIndex, int adIndexInAdGroup) {
this.adGroupIndex = adGroupIndex;
this.adIndexInAdGroup = adIndexInAdGroup;
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
AdInfo adInfo = (AdInfo) o;
if (adGroupIndex != adInfo.adGroupIndex) {
return false;
}
return adIndexInAdGroup == adInfo.adIndexInAdGroup;
}
@Override
public int hashCode() {
int result = adGroupIndex;
result = 31 * result + adIndexInAdGroup;
return result;
}
@Override
public String toString() {
return "(" + adGroupIndex + ", " + adIndexInAdGroup + ')';
}
}
}
......@@ -21,30 +21,19 @@ import static com.google.android.exoplayer2.ext.ima.ImaUtil.getImaLooper;
import static com.google.android.exoplayer2.util.Assertions.checkArgument;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Assertions.checkState;
import static java.lang.Math.max;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
import com.google.ads.interactivemedia.v3.api.AdError;
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.AdEvent.AdEventType;
import com.google.ads.interactivemedia.v3.api.AdPodInfo;
import com.google.ads.interactivemedia.v3.api.AdsLoader;
import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener;
import com.google.ads.interactivemedia.v3.api.AdsManager;
import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
import com.google.ads.interactivemedia.v3.api.AdsRequest;
import com.google.ads.interactivemedia.v3.api.CompanionAdSlot;
......@@ -53,40 +42,24 @@ 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.AdMediaInfo;
import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider;
import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer;
import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.MediaSourceFactory;
import com.google.android.exoplayer2.source.ads.AdPlaybackState;
import com.google.android.exoplayer2.source.ads.AdsMediaSource;
import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.Util;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* {@link com.google.android.exoplayer2.source.ads.AdsLoader} using the IMA SDK. All methods must be
......@@ -435,141 +408,19 @@ public final class ImaAdsLoader
}
}
private static final String TAG = "ImaAdsLoader";
private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima";
private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION;
/**
* Interval at which ad progress updates are provided to the IMA SDK, in milliseconds. 100 ms is
* the interval recommended by the IMA documentation.
*
* @see VideoAdPlayer.VideoAdPlayerCallback
*/
private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100;
/** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */
private static final long IMA_DURATION_UNSET = -1L;
/**
* Threshold before the end of content at which IMA is notified that content is complete if the
* player buffers, in milliseconds.
*/
private static final long THRESHOLD_END_OF_CONTENT_MS = 5000;
/**
* Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in
* milliseconds.
*/
private static final long THRESHOLD_AD_PRELOAD_MS = 4000;
/** The threshold below which ad cue points are treated as matching, in microseconds. */
private static final long THRESHOLD_AD_MATCH_US = 1000;
/** The state of ad playback. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED})
private @interface ImaAdState {}
/** The ad playback state when IMA is not playing an ad. */
private static final int IMA_AD_STATE_NONE = 0;
/**
* The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not
* {@link ComponentListener##pauseAd(AdMediaInfo)}.
*/
private static final int IMA_AD_STATE_PLAYING = 1;
/**
* The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while
* playing an ad.
*/
private static final int IMA_AD_STATE_PAUSED = 2;
private static final DataSpec EMPTY_AD_TAG_DATA_SPEC = new DataSpec(Uri.EMPTY);
private final ImaUtil.Configuration configuration;
private final Context context;
private final ImaUtil.ImaFactory imaFactory;
@Nullable private final DataSpec deprecatedAdTagDataSpec;
private final ImaSdkSettings imaSdkSettings;
private final Timeline.Period period;
private final Handler handler;
private final ComponentListener componentListener;
private final List<VideoAdPlayer.VideoAdPlayerCallback> adCallbacks;
private final Runnable updateAdProgressRunnable;
private final BiMap<AdMediaInfo, AdInfo> adInfoByAdMediaInfo;
private @MonotonicNonNull AdDisplayContainer adDisplayContainer;
private @MonotonicNonNull AdsLoader adsLoader;
private boolean wasSetPlayerCalled;
@Nullable private Player nextPlayer;
@Nullable private Object pendingAdRequestContext;
@Nullable private AdTagLoader adTagLoader;
private List<String> supportedMimeTypes;
@Nullable private EventListener eventListener;
@Nullable private Player player;
private DataSpec adTagDataSpec;
private VideoProgressUpdate lastContentProgress;
private VideoProgressUpdate lastAdProgress;
private int lastVolumePercent;
@Nullable private AdsManager adsManager;
private boolean isAdsManagerInitialized;
private boolean hasAdPlaybackState;
@Nullable private AdLoadException pendingAdLoadError;
private Timeline timeline;
private long contentDurationMs;
private AdPlaybackState adPlaybackState;
// Fields tracking IMA's state.
/** Whether IMA has sent an ad event to pause content since the last resume content event. */
private boolean imaPausedContent;
/** The current ad playback state. */
private @ImaAdState int imaAdState;
/** The current ad media info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */
@Nullable private AdMediaInfo imaAdMediaInfo;
/** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */
@Nullable private AdInfo imaAdInfo;
/** Whether IMA has been notified that playback of content has finished. */
private boolean sentContentComplete;
// Fields tracking the player/loader state.
/** Whether the player is playing an ad. */
private boolean playingAd;
/** Whether the player is buffering an ad. */
private boolean bufferingAd;
/**
* If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET}
* otherwise.
*/
private int playingAdIndexInAdGroup;
/**
* The ad info for a pending ad for which the media failed preparation, or {@code null} if no
* pending ads have failed to prepare.
*/
@Nullable private AdInfo pendingAdPrepareErrorAdInfo;
/**
* If a content period has finished but IMA has not yet called {@link
* ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link
* SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine
* a fake, increasing content position. {@link C#TIME_UNSET} otherwise.
*/
private long fakeContentProgressElapsedRealtimeMs;
/**
* If {@link #fakeContentProgressElapsedRealtimeMs} is set, stores the offset from which the
* content progress should increase. {@link C#TIME_UNSET} otherwise.
*/
private long fakeContentProgressOffsetMs;
/** Stores the pending content position when a seek operation was intercepted to play an ad. */
private long pendingContentPositionMs;
/**
* Whether {@link ComponentListener#getContentProgress()} has sent {@link
* #pendingContentPositionMs} to IMA.
*/
private boolean sentPendingContentPositionMs;
/**
* Stores the real time in milliseconds at which the player started buffering, possibly due to not
* having preloaded an ad, or {@link C#TIME_UNSET} if not applicable.
*/
private long waitingForPreloadElapsedRealtimeMs;
@Nullable private Player player;
/**
* Creates a new IMA ads loader.
......@@ -594,7 +445,6 @@ public final class ImaAdsLoader
/* adsResponse= */ null);
}
@SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"})
private ImaAdsLoader(
Context context,
ImaUtil.Configuration configuration,
......@@ -610,36 +460,8 @@ public final class ImaAdsLoader
: adsResponse != null
? new DataSpec(Util.getDataUriForString(adsResponse, "text/xml"))
: null;
@Nullable ImaSdkSettings imaSdkSettings = configuration.imaSdkSettings;
if (imaSdkSettings == null) {
imaSdkSettings = imaFactory.createImaSdkSettings();
if (configuration.debugModeEnabled) {
imaSdkSettings.setDebugMode(true);
}
}
imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE);
imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION);
this.imaSdkSettings = imaSdkSettings;
period = new Timeline.Period();
handler = Util.createHandler(getImaLooper(), /* callback= */ null);
componentListener = new ComponentListener();
adCallbacks = new ArrayList<>(/* initialCapacity= */ 1);
if (configuration.applicationVideoAdPlayerCallback != null) {
adCallbacks.add(configuration.applicationVideoAdPlayerCallback);
}
updateAdProgressRunnable = this::updateAdProgress;
adInfoByAdMediaInfo = HashBiMap.create();
supportedMimeTypes = Collections.emptyList();
adTagDataSpec = EMPTY_AD_TAG_DATA_SPEC;
lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
fakeContentProgressOffsetMs = C.TIME_UNSET;
pendingContentPositionMs = C.TIME_UNSET;
waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
contentDurationMs = C.TIME_UNSET;
timeline = Timeline.EMPTY;
adPlaybackState = AdPlaybackState.NONE;
supportedMimeTypes = ImmutableList.of();
}
/**
......@@ -648,7 +470,7 @@ public final class ImaAdsLoader
*/
@Nullable
public AdsLoader getAdsLoader() {
return adsLoader;
return adTagLoader != null ? adTagLoader.getAdsLoader() : null;
}
/**
......@@ -664,7 +486,7 @@ public final class ImaAdsLoader
*/
@Nullable
public AdDisplayContainer getAdDisplayContainer() {
return adDisplayContainer;
return adTagLoader != null ? adTagLoader.getAdDisplayContainer() : null;
}
/**
......@@ -698,51 +520,16 @@ public final class ImaAdsLoader
* null} if playing audio-only ads.
*/
public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) {
if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) {
// Ads have already been requested.
if (adTagLoader != null) {
return;
}
if (EMPTY_AD_TAG_DATA_SPEC.equals(adTagDataSpec)) {
adTagDataSpec = checkNotNull(deprecatedAdTagDataSpec);
}
AdsRequest request;
try {
request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec);
} catch (IOException e) {
hasAdPlaybackState = true;
updateAdPlaybackState();
pendingAdLoadError = AdLoadException.createForAllAds(e);
maybeNotifyPendingAdLoadError();
return;
}
this.adTagDataSpec = adTagDataSpec;
pendingAdRequestContext = new Object();
request.setUserRequestContext(pendingAdRequestContext);
if (configuration.vastLoadTimeoutMs != TIMEOUT_UNSET) {
request.setVastLoadTimeout(configuration.vastLoadTimeoutMs);
}
request.setContentProgressProvider(componentListener);
if (adViewGroup != null) {
adDisplayContainer =
imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener);
} else {
adDisplayContainer =
imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener);
}
if (configuration.companionAdSlots != null) {
adDisplayContainer.setCompanionSlots(configuration.companionAdSlots);
}
adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer);
adsLoader.addAdErrorListener(componentListener);
if (configuration.applicationAdErrorListener != null) {
adsLoader.addAdErrorListener(configuration.applicationAdErrorListener);
}
adsLoader.addAdsLoadedListener(componentListener);
adsLoader.requestAds(request);
adTagLoader =
new AdTagLoader(
context, configuration, imaFactory, supportedMimeTypes, adTagDataSpec, adViewGroup);
}
/**
......@@ -753,8 +540,8 @@ public final class ImaAdsLoader
* IMA SDK provides the UI to skip ads in the ad view group passed via {@link AdViewProvider}.
*/
public void skipAd() {
if (adsManager != null) {
adsManager.skip();
if (adTagLoader != null) {
adTagLoader.skipAd();
}
}
......@@ -800,1096 +587,41 @@ public final class ImaAdsLoader
checkState(
wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player.");
player = nextPlayer;
@Nullable Player player = this.player;
if (player == null) {
return;
}
player.addListener(this);
boolean playWhenReady = player.getPlayWhenReady();
this.eventListener = eventListener;
lastVolumePercent = 0;
lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY;
maybeNotifyPendingAdLoadError();
if (hasAdPlaybackState) {
// Pass the ad playback state to the player, and resume ads if necessary.
eventListener.onAdPlaybackState(adPlaybackState);
if (adsManager != null && imaPausedContent && playWhenReady) {
adsManager.resume();
}
} else if (adsManager != null) {
adPlaybackState = ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints());
updateAdPlaybackState();
} else {
// Ads haven't loaded yet, so request them.
if (adTagLoader == null) {
requestAds(adTagDataSpec, adViewProvider.getAdViewGroup());
}
if (adDisplayContainer != null) {
for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) {
adDisplayContainer.registerFriendlyObstruction(
imaFactory.createFriendlyObstruction(
overlayInfo.view,
ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose),
overlayInfo.reasonDetail));
}
}
checkNotNull(adTagLoader).start(player, adViewProvider, eventListener);
}
@Override
public void stop() {
@Nullable Player player = this.player;
if (player == null) {
return;
if (player != null && adTagLoader != null) {
adTagLoader.stop();
}
if (adsManager != null && imaPausedContent) {
adsManager.pause();
adPlaybackState =
adPlaybackState.withAdResumePositionUs(
playingAd ? C.msToUs(player.getCurrentPosition()) : 0);
}
lastVolumePercent = getPlayerVolumePercent();
lastAdProgress = getAdVideoProgressUpdate();
lastContentProgress = getContentVideoProgressUpdate();
if (adDisplayContainer != null) {
adDisplayContainer.unregisterAllFriendlyObstructions();
}
player.removeListener(this);
this.player = null;
eventListener = null;
}
@Override
public void release() {
pendingAdRequestContext = null;
destroyAdsManager();
if (adsLoader != null) {
adsLoader.removeAdsLoadedListener(componentListener);
adsLoader.removeAdErrorListener(componentListener);
if (configuration.applicationAdErrorListener != null) {
adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener);
}
adsLoader.release();
if (adTagLoader != null) {
adTagLoader.release();
}
imaPausedContent = false;
imaAdState = IMA_AD_STATE_NONE;
imaAdMediaInfo = null;
stopUpdatingAdProgress();
imaAdInfo = null;
pendingAdLoadError = null;
adPlaybackState = AdPlaybackState.NONE;
hasAdPlaybackState = true;
updateAdPlaybackState();
}
@Override
public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) {
AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
if (configuration.debugModeEnabled) {
Log.d(TAG, "Prepared ad " + adInfo);
}
@Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo);
if (adMediaInfo != null) {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onLoaded(adMediaInfo);
}
} else {
Log.w(TAG, "Unexpected prepared ad " + adInfo);
if (adTagLoader != null) {
adTagLoader.handlePrepareComplete(adGroupIndex, adIndexInAdGroup);
}
}
@Override
public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {
if (player == null) {
return;
}
try {
handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception);
} catch (RuntimeException e) {
maybeNotifyInternalError("handlePrepareError", e);
}
}
// Player.EventListener implementation.
@Override
public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
if (timeline.isEmpty()) {
// The player is being reset or contains no media.
return;
}
checkArgument(timeline.getPeriodCount() == 1);
this.timeline = timeline;
long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs;
contentDurationMs = C.usToMs(contentDurationUs);
if (contentDurationUs != C.TIME_UNSET) {
adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs);
}
@Nullable AdsManager adsManager = this.adsManager;
if (!isAdsManagerInitialized && adsManager != null) {
isAdsManagerInitialized = true;
@Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering();
if (adsRenderingSettings == null) {
// There are no ads to play.
destroyAdsManager();
} else {
adsManager.init(adsRenderingSettings);
adsManager.start();
if (configuration.debugModeEnabled) {
Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings);
}
}
updateAdPlaybackState();
}
handleTimelineOrPositionChanged();
}
@Override
public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
handleTimelineOrPositionChanged();
}
@Override
public void onPlaybackStateChanged(@Player.State int playbackState) {
@Nullable Player player = this.player;
if (adsManager == null || player == null) {
return;
}
if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) {
// Check whether we are waiting for an ad to preload.
int adGroupIndex = getLoadingAdGroupIndex();
if (adGroupIndex == C.INDEX_UNSET) {
return;
}
AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
if (adGroup.count != C.LENGTH_UNSET
&& adGroup.count != 0
&& adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) {
// An ad is available already so we must be buffering for some other reason.
return;
}
long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
long contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
long timeUntilAdMs = adGroupTimeMs - contentPositionMs;
if (timeUntilAdMs < configuration.adPreloadTimeoutMs) {
waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime();
}
} else if (playbackState == Player.STATE_READY) {
waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
}
handlePlayerStateChanged(player.getPlayWhenReady(), playbackState);
}
@Override
public void onPlayWhenReadyChanged(
boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
if (adsManager == null || player == null) {
return;
}
if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) {
adsManager.pause();
return;
}
if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) {
adsManager.resume();
return;
}
handlePlayerStateChanged(playWhenReady, player.getPlaybackState());
}
@Override
public void onPlayerError(ExoPlaybackException error) {
if (imaAdState != IMA_AD_STATE_NONE) {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onError(adMediaInfo);
}
}
}
// Internal methods.
/**
* Configures ads rendering for starting playback, returning the settings for the IMA SDK or
* {@code null} if no ads should play.
*/
@Nullable
private AdsRenderingSettings setupAdsRendering() {
AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings();
adsRenderingSettings.setEnablePreloading(true);
adsRenderingSettings.setMimeTypes(
configuration.adMediaMimeTypes != null
? configuration.adMediaMimeTypes
: supportedMimeTypes);
if (configuration.mediaLoadTimeoutMs != TIMEOUT_UNSET) {
adsRenderingSettings.setLoadVideoTimeout(configuration.mediaLoadTimeoutMs);
}
if (configuration.mediaBitrate != BITRATE_UNSET) {
adsRenderingSettings.setBitrateKbps(configuration.mediaBitrate / 1000);
}
adsRenderingSettings.setFocusSkipButtonWhenAvailable(
configuration.focusSkipButtonWhenAvailable);
if (configuration.adUiElements != null) {
adsRenderingSettings.setUiElements(configuration.adUiElements);
}
// Skip ads based on the start position as required.
long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs;
long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period);
int adGroupForPositionIndex =
adPlaybackState.getAdGroupIndexForPositionUs(
C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
if (adGroupForPositionIndex != C.INDEX_UNSET) {
boolean playAdWhenStartingPlayback =
configuration.playAdBeforeStartPosition
|| adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs);
if (!playAdWhenStartingPlayback) {
adGroupForPositionIndex++;
} else if (hasMidrollAdGroups(adGroupTimesUs)) {
// Provide the player's initial position to trigger loading and playing the ad. If there are
// no midrolls, we are playing a preroll and any pending content position wouldn't be
// cleared.
pendingContentPositionMs = contentPositionMs;
}
if (adGroupForPositionIndex > 0) {
for (int i = 0; i < adGroupForPositionIndex; i++) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
}
if (adGroupForPositionIndex == adGroupTimesUs.length) {
// We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP
// ads, we signal that no ads will render so the caller can destroy the ads manager.
return null;
}
long adGroupForPositionTimeUs = adGroupTimesUs[adGroupForPositionIndex];
long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1];
if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) {
// Play the postroll by offsetting the start position just past the last non-postroll ad.
adsRenderingSettings.setPlayAdsAfterTime(
(double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d);
} else {
// Play ads after the midpoint between the ad to play and the one before it, to avoid
// issues with rounding one of the two ad times.
double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d;
adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND);
}
}
}
return adsRenderingSettings;
}
private VideoProgressUpdate getContentVideoProgressUpdate() {
if (player == null) {
return lastContentProgress;
}
boolean hasContentDuration = contentDurationMs != C.TIME_UNSET;
long contentPositionMs;
if (pendingContentPositionMs != C.TIME_UNSET) {
sentPendingContentPositionMs = true;
contentPositionMs = pendingContentPositionMs;
} else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) {
long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs;
contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs;
} else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) {
contentPositionMs = getContentPeriodPositionMs(player, timeline, period);
} else {
return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
}
long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET;
return new VideoProgressUpdate(contentPositionMs, contentDurationMs);
}
private VideoProgressUpdate getAdVideoProgressUpdate() {
if (player == null) {
return lastAdProgress;
} else if (imaAdState != IMA_AD_STATE_NONE && playingAd) {
long adDuration = player.getDuration();
return adDuration == C.TIME_UNSET
? VideoProgressUpdate.VIDEO_TIME_NOT_READY
: new VideoProgressUpdate(player.getCurrentPosition(), adDuration);
} else {
return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
}
}
private void updateAdProgress() {
VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate();
if (configuration.debugModeEnabled) {
Log.d(TAG, "Ad progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate));
}
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate);
}
handler.removeCallbacks(updateAdProgressRunnable);
handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS);
}
private void stopUpdatingAdProgress() {
handler.removeCallbacks(updateAdProgressRunnable);
}
private int getPlayerVolumePercent() {
@Nullable Player player = this.player;
if (player == null) {
return lastVolumePercent;
}
@Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
if (audioComponent != null) {
return (int) (audioComponent.getVolume() * 100);
}
// Check for a selected track using an audio renderer.
TrackSelectionArray trackSelections = player.getCurrentTrackSelections();
for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) {
if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) {
return 100;
}
}
return 0;
}
private void handleAdEvent(AdEvent adEvent) {
if (adsManager == null) {
// Drop events after release.
return;
}
switch (adEvent.getType()) {
case AD_BREAK_FETCH_ERROR:
String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime"));
if (configuration.debugModeEnabled) {
Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds");
}
double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString);
int adGroupIndex =
adGroupTimeSeconds == -1.0
? adPlaybackState.adGroupCount - 1
: getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds);
markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex);
break;
case CONTENT_PAUSE_REQUESTED:
// After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads
// before sending CONTENT_RESUME_REQUESTED.
imaPausedContent = true;
pauseContentInternal();
break;
case TAPPED:
if (eventListener != null) {
eventListener.onAdTapped();
}
break;
case CLICKED:
if (eventListener != null) {
eventListener.onAdClicked();
}
break;
case CONTENT_RESUME_REQUESTED:
imaPausedContent = false;
resumeContentInternal();
break;
case LOG:
Map<String, String> adData = adEvent.getAdData();
String message = "AdEvent: " + adData;
Log.i(TAG, message);
break;
default:
break;
}
}
private void pauseContentInternal() {
imaAdState = IMA_AD_STATE_NONE;
if (sentPendingContentPositionMs) {
pendingContentPositionMs = C.TIME_UNSET;
sentPendingContentPositionMs = false;
}
}
private void resumeContentInternal() {
if (imaAdInfo != null) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex);
updateAdPlaybackState();
} else {
// Mark any ads for the current/reported player position that haven't loaded as being in the
// error state, to force resuming content. This includes VPAID ads that never load.
long playerPositionUs;
if (player != null) {
playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period));
} else if (!VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(lastContentProgress)) {
// Playback is backgrounded so use the last reported content position.
playerPositionUs = C.msToUs(lastContentProgress.getCurrentTimeMs());
} else {
return;
}
int adGroupIndex =
adPlaybackState.getAdGroupIndexForPositionUs(
playerPositionUs, C.msToUs(contentDurationMs));
if (adGroupIndex != C.INDEX_UNSET) {
markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex);
}
}
}
private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) {
if (!bufferingAd && playbackState == Player.STATE_BUFFERING) {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onBuffering(adMediaInfo);
}
stopUpdatingAdProgress();
} else if (bufferingAd && playbackState == Player.STATE_READY) {
bufferingAd = false;
updateAdProgress();
}
}
if (imaAdState == IMA_AD_STATE_NONE
&& playbackState == Player.STATE_BUFFERING
&& playWhenReady) {
ensureSentContentCompleteIfAtEndOfStream();
} else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
if (adMediaInfo == null) {
Log.w(TAG, "onEnded without ad media info");
} else {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onEnded(adMediaInfo);
}
}
if (configuration.debugModeEnabled) {
Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged");
}
}
}
private void handleTimelineOrPositionChanged() {
@Nullable Player player = this.player;
if (adsManager == null || player == null) {
return;
}
if (!playingAd && !player.isPlayingAd()) {
ensureSentContentCompleteIfAtEndOfStream();
if (!sentContentComplete && !timeline.isEmpty()) {
long positionMs = getContentPeriodPositionMs(player, timeline, period);
timeline.getPeriod(/* periodIndex= */ 0, period);
int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs));
if (newAdGroupIndex != C.INDEX_UNSET) {
sentPendingContentPositionMs = false;
pendingContentPositionMs = positionMs;
}
}
}
boolean wasPlayingAd = playingAd;
int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup;
playingAd = player.isPlayingAd();
playingAdIndexInAdGroup = playingAd ? player.getCurrentAdIndexInAdGroup() : C.INDEX_UNSET;
boolean adFinished = wasPlayingAd && playingAdIndexInAdGroup != oldPlayingAdIndexInAdGroup;
if (adFinished) {
// IMA is waiting for the ad playback to finish so invoke the callback now.
// Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again.
@Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo;
if (adMediaInfo == null) {
Log.w(TAG, "onEnded without ad media info");
} else {
@Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
if (playingAdIndexInAdGroup == C.INDEX_UNSET
|| (adInfo != null && adInfo.adIndexInAdGroup < playingAdIndexInAdGroup)) {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onEnded(adMediaInfo);
}
if (configuration.debugModeEnabled) {
Log.d(
TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity");
}
}
}
}
if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) {
int adGroupIndex = player.getCurrentAdGroupIndex();
if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) {
sendContentComplete();
} else {
// IMA hasn't called playAd yet, so fake the content position.
fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
fakeContentProgressOffsetMs = contentDurationMs;
}
}
}
}
private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
if (adsManager == null) {
// Drop events after release.
if (configuration.debugModeEnabled) {
Log.d(
TAG,
"loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo);
}
return;
}
int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo);
int adIndexInAdGroup = adPodInfo.getAdPosition() - 1;
AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
adInfoByAdMediaInfo.put(adMediaInfo, adInfo);
if (configuration.debugModeEnabled) {
Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo));
}
if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) {
// We have already marked this ad as having failed to load, so ignore the request. IMA will
// timeout after its media load timeout.
return;
}
// The ad count may increase on successive loads of ads in the same ad pod, for example, due to
// separate requests for ad tags with multiple ads within the ad pod completing after an earlier
// ad has loaded. See also https://github.com/google/ExoPlayer/issues/7477.
AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex];
adPlaybackState =
adPlaybackState.withAdCount(
adInfo.adGroupIndex, max(adPodInfo.getTotalAds(), adGroup.states.length));
adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex];
for (int i = 0; i < adIndexInAdGroup; i++) {
// Any preceding ads that haven't loaded are not going to load.
if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) {
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, /* adIndexInAdGroup= */ i);
}
}
Uri adUri = Uri.parse(adMediaInfo.getUrl());
adPlaybackState =
adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri);
updateAdPlaybackState();
}
private void playAdInternal(AdMediaInfo adMediaInfo) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo));
}
if (adsManager == null) {
// Drop events after release.
return;
}
if (imaAdState == IMA_AD_STATE_PLAYING) {
// IMA does not always call stopAd before resuming content.
// See [Internal: b/38354028].
Log.w(TAG, "Unexpected playAd without stopAd");
}
if (imaAdState == IMA_AD_STATE_NONE) {
// IMA is requesting to play the ad, so stop faking the content position.
fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
fakeContentProgressOffsetMs = C.TIME_UNSET;
imaAdState = IMA_AD_STATE_PLAYING;
imaAdMediaInfo = adMediaInfo;
imaAdInfo = checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo));
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onPlay(adMediaInfo);
}
if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) {
pendingAdPrepareErrorAdInfo = null;
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onError(adMediaInfo);
}
}
updateAdProgress();
} else {
imaAdState = IMA_AD_STATE_PLAYING;
checkState(adMediaInfo.equals(imaAdMediaInfo));
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onResume(adMediaInfo);
}
}
if (!checkNotNull(player).getPlayWhenReady()) {
checkNotNull(adsManager).pause();
}
}
private void pauseAdInternal(AdMediaInfo adMediaInfo) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo));
}
if (adsManager == null) {
// Drop event after release.
return;
}
if (imaAdState == IMA_AD_STATE_NONE) {
// This method is called if loadAd has been called but the loaded ad won't play due to a seek
// to a different position, so drop the event. See also [Internal: b/159111848].
return;
}
checkState(adMediaInfo.equals(imaAdMediaInfo));
imaAdState = IMA_AD_STATE_PAUSED;
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onPause(adMediaInfo);
}
}
private void stopAdInternal(AdMediaInfo adMediaInfo) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo));
}
if (adsManager == null) {
// Drop event after release.
return;
}
if (imaAdState == IMA_AD_STATE_NONE) {
// This method is called if loadAd has been called but the preloaded ad won't play due to a
// seek to a different position, so drop the event and discard the ad. See also [Internal:
// b/159111848].
@Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
if (adInfo != null) {
adPlaybackState =
adPlaybackState.withSkippedAd(adInfo.adGroupIndex, adInfo.adIndexInAdGroup);
updateAdPlaybackState();
}
return;
}
checkNotNull(player);
imaAdState = IMA_AD_STATE_NONE;
stopUpdatingAdProgress();
// TODO: Handle the skipped event so the ad can be marked as skipped rather than played.
checkNotNull(imaAdInfo);
int adGroupIndex = imaAdInfo.adGroupIndex;
int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup;
if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) {
// We have already marked this ad as having failed to load, so ignore the request.
return;
}
adPlaybackState =
adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0);
updateAdPlaybackState();
if (!playingAd) {
imaAdMediaInfo = null;
imaAdInfo = null;
}
}
private void handleAdGroupLoadError(Exception error) {
int adGroupIndex = getLoadingAdGroupIndex();
if (adGroupIndex == C.INDEX_UNSET) {
Log.w(TAG, "Unable to determine ad group index for ad group load error", error);
return;
}
markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex);
if (pendingAdLoadError == null) {
pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex);
}
}
private void markAdGroupInErrorStateAndClearPendingContentPosition(int adGroupIndex) {
// Update the ad playback state so all ads in the ad group are in the error state.
AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
if (adGroup.count == C.LENGTH_UNSET) {
adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length));
adGroup = adPlaybackState.adGroups[adGroupIndex];
}
for (int i = 0; i < adGroup.count; i++) {
if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) {
if (configuration.debugModeEnabled) {
Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex);
}
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i);
}
}
updateAdPlaybackState();
// Clear any pending content position that triggered attempting to load the ad group.
pendingContentPositionMs = C.TIME_UNSET;
fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
}
private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Exception exception) {
if (configuration.debugModeEnabled) {
Log.d(
TAG, "Prepare error for ad " + adIndexInAdGroup + " in group " + adGroupIndex, exception);
}
if (adsManager == null) {
Log.w(TAG, "Ignoring ad prepare error after release");
return;
}
if (imaAdState == IMA_AD_STATE_NONE) {
// Send IMA a content position at the ad group so that it will try to play it, at which point
// we can notify that it failed to load.
fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
fakeContentProgressOffsetMs = contentDurationMs;
}
pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup);
} else {
AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo);
// We're already playing an ad.
if (adIndexInAdGroup > playingAdIndexInAdGroup) {
// Mark the playing ad as ended so we can notify the error on the next ad and remove it,
// which means that the ad after will load (if any).
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onEnded(adMediaInfo);
}
}
playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay();
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onError(checkNotNull(adMediaInfo));
}
}
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup);
updateAdPlaybackState();
}
private void ensureSentContentCompleteIfAtEndOfStream() {
if (!sentContentComplete
&& contentDurationMs != C.TIME_UNSET
&& pendingContentPositionMs == C.TIME_UNSET
&& getContentPeriodPositionMs(checkNotNull(player), timeline, period)
+ THRESHOLD_END_OF_CONTENT_MS
>= contentDurationMs) {
sendContentComplete();
}
}
private void sendContentComplete() {
for (int i = 0; i < adCallbacks.size(); i++) {
adCallbacks.get(i).onContentComplete();
}
sentContentComplete = true;
if (configuration.debugModeEnabled) {
Log.d(TAG, "adsLoader.contentComplete");
}
for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i);
}
}
updateAdPlaybackState();
}
private void updateAdPlaybackState() {
// Ignore updates while detached. When a player is attached it will receive the latest state.
if (eventListener != null) {
eventListener.onAdPlaybackState(adPlaybackState);
}
}
private void maybeNotifyPendingAdLoadError() {
if (pendingAdLoadError != null && eventListener != null) {
eventListener.onAdLoadError(pendingAdLoadError, adTagDataSpec);
pendingAdLoadError = null;
}
}
private void maybeNotifyInternalError(String name, Exception cause) {
String message = "Internal error in " + name;
Log.e(TAG, message, cause);
// We can't recover from an unexpected error in general, so skip all remaining ads.
for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
}
updateAdPlaybackState();
if (eventListener != null) {
eventListener.onAdLoadError(
AdLoadException.createForUnexpected(new RuntimeException(message, cause)), adTagDataSpec);
}
}
private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) {
if (adPodInfo.getPodIndex() == -1) {
// This is a postroll ad.
return adPlaybackState.adGroupCount - 1;
}
// adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead.
return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset());
}
/**
* Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is
* no such ad group.
*/
private int getLoadingAdGroupIndex() {
if (player == null) {
return C.INDEX_UNSET;
}
long playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period));
int adGroupIndex =
adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs));
if (adGroupIndex == C.INDEX_UNSET) {
adGroupIndex =
adPlaybackState.getAdGroupIndexAfterPositionUs(
playerPositionUs, C.msToUs(contentDurationMs));
}
return adGroupIndex;
}
private int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) {
// We receive initial cue points from IMA SDK as floats. This code replicates the same
// calculation used to populate adGroupTimesUs (having truncated input back to float, to avoid
// failures if the behavior of the IMA SDK changes to provide greater precision).
float cuePointTimeSecondsFloat = (float) cuePointTimeSeconds;
long adPodTimeUs = Math.round((double) cuePointTimeSecondsFloat * C.MICROS_PER_SECOND);
for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex];
if (adGroupTimeUs != C.TIME_END_OF_SOURCE
&& Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) {
return adGroupIndex;
}
}
throw new IllegalStateException("Failed to find cue point");
}
private String getAdMediaInfoString(AdMediaInfo adMediaInfo) {
@Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo);
return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]";
}
private static long getContentPeriodPositionMs(
Player player, Timeline timeline, Timeline.Period period) {
long contentWindowPositionMs = player.getContentPosition();
return contentWindowPositionMs
- (timeline.isEmpty()
? 0
: timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs());
}
private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) {
int count = adGroupTimesUs.length;
if (count == 1) {
return adGroupTimesUs[0] != 0 && adGroupTimesUs[0] != C.TIME_END_OF_SOURCE;
} else if (count == 2) {
return adGroupTimesUs[0] != 0 || adGroupTimesUs[1] != C.TIME_END_OF_SOURCE;
} else {
// There's at least one midroll ad group, as adGroupTimesUs is never empty.
return true;
}
}
private void destroyAdsManager() {
if (adsManager != null) {
adsManager.removeAdErrorListener(componentListener);
if (configuration.applicationAdErrorListener != null) {
adsManager.removeAdErrorListener(configuration.applicationAdErrorListener);
}
adsManager.removeAdEventListener(componentListener);
if (configuration.applicationAdEventListener != null) {
adsManager.removeAdEventListener(configuration.applicationAdEventListener);
}
adsManager.destroy();
adsManager = null;
}
}
private final class ComponentListener
implements AdsLoadedListener,
ContentProgressProvider,
AdEventListener,
AdErrorListener,
VideoAdPlayer {
// AdsLoader.AdsLoadedListener implementation.
@Override
public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) {
AdsManager adsManager = adsManagerLoadedEvent.getAdsManager();
if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) {
adsManager.destroy();
return;
}
pendingAdRequestContext = null;
ImaAdsLoader.this.adsManager = adsManager;
adsManager.addAdErrorListener(this);
if (configuration.applicationAdErrorListener != null) {
adsManager.addAdErrorListener(configuration.applicationAdErrorListener);
}
adsManager.addAdEventListener(this);
if (configuration.applicationAdEventListener != null) {
adsManager.addAdEventListener(configuration.applicationAdEventListener);
}
if (player != null) {
// If a player is attached already, start playback immediately.
try {
adPlaybackState =
ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints());
hasAdPlaybackState = true;
updateAdPlaybackState();
} catch (RuntimeException e) {
maybeNotifyInternalError("onAdsManagerLoaded", e);
}
}
}
// ContentProgressProvider implementation.
@Override
public VideoProgressUpdate getContentProgress() {
VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate();
if (configuration.debugModeEnabled) {
Log.d(
TAG,
"Content progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate));
}
if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) {
// IMA is polling the player position but we are buffering for an ad to preload, so playback
// may be stuck. Detect this case and signal an error if applicable.
long stuckElapsedRealtimeMs =
SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs;
if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) {
waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET;
handleAdGroupLoadError(new IOException("Ad preloading timed out"));
maybeNotifyPendingAdLoadError();
}
}
return videoProgressUpdate;
}
// AdEvent.AdEventListener implementation.
@Override
public void onAdEvent(AdEvent adEvent) {
AdEventType adEventType = adEvent.getType();
if (configuration.debugModeEnabled && adEventType != AdEventType.AD_PROGRESS) {
Log.d(TAG, "onAdEvent: " + adEventType);
}
try {
handleAdEvent(adEvent);
} catch (RuntimeException e) {
maybeNotifyInternalError("onAdEvent", e);
}
}
// AdErrorEvent.AdErrorListener implementation.
@Override
public void onAdError(AdErrorEvent adErrorEvent) {
AdError error = adErrorEvent.getError();
if (configuration.debugModeEnabled) {
Log.d(TAG, "onAdError", error);
}
if (adsManager == null) {
// No ads were loaded, so allow playback to start without any ads.
pendingAdRequestContext = null;
adPlaybackState = AdPlaybackState.NONE;
hasAdPlaybackState = true;
updateAdPlaybackState();
} else if (ImaUtil.isAdGroupLoadError(error)) {
try {
handleAdGroupLoadError(error);
} catch (RuntimeException e) {
maybeNotifyInternalError("onAdError", e);
}
}
if (pendingAdLoadError == null) {
pendingAdLoadError = AdLoadException.createForAllAds(error);
}
maybeNotifyPendingAdLoadError();
}
// VideoAdPlayer implementation.
@Override
public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
adCallbacks.add(videoAdPlayerCallback);
}
@Override
public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
adCallbacks.remove(videoAdPlayerCallback);
}
@Override
public VideoProgressUpdate getAdProgress() {
throw new IllegalStateException("Unexpected call to getAdProgress when using preloading");
}
@Override
public int getVolume() {
return getPlayerVolumePercent();
}
@Override
public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) {
try {
loadAdInternal(adMediaInfo, adPodInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("loadAd", e);
}
}
@Override
public void playAd(AdMediaInfo adMediaInfo) {
try {
playAdInternal(adMediaInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("playAd", e);
}
}
@Override
public void pauseAd(AdMediaInfo adMediaInfo) {
try {
pauseAdInternal(adMediaInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("pauseAd", e);
}
}
@Override
public void stopAd(AdMediaInfo adMediaInfo) {
try {
stopAdInternal(adMediaInfo);
} catch (RuntimeException e) {
maybeNotifyInternalError("stopAd", e);
}
}
@Override
public void release() {
// Do nothing.
}
}
// TODO: Consider moving this into AdPlaybackState.
private static final class AdInfo {
public final int adGroupIndex;
public final int adIndexInAdGroup;
public AdInfo(int adGroupIndex, int adIndexInAdGroup) {
this.adGroupIndex = adGroupIndex;
this.adIndexInAdGroup = adIndexInAdGroup;
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
AdInfo adInfo = (AdInfo) o;
if (adGroupIndex != adInfo.adGroupIndex) {
return false;
}
return adIndexInAdGroup == adInfo.adIndexInAdGroup;
}
@Override
public int hashCode() {
int result = adGroupIndex;
result = 31 * result + adIndexInAdGroup;
return result;
}
@Override
public String toString() {
return "(" + adGroupIndex + ", " + adIndexInAdGroup + ')';
if (adTagLoader != null) {
adTagLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception);
}
}
......
......@@ -172,6 +172,7 @@ public final class ImaAdsLoaderTest {
public void builder_overridesPlayerType() {
when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type");
setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS);
imaAdsLoader.start(adsLoaderListener, adViewProvider);
verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima");
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment