diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 743e68a9a3..b632e7ba84 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -48,6 +48,7 @@ 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.AdPlaybackState.AdState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; @@ -393,7 +394,7 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A maybeNotifyAdError(); if (adPlaybackState != null) { // Pass the ad playback state to the player, and resume ads if necessary. - eventListener.onAdPlaybackState(adPlaybackState.copy()); + eventListener.onAdPlaybackState(adPlaybackState); if (imaPausedContent && player.getPlayWhenReady()) { adsManager.resume(); } @@ -409,7 +410,9 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A @Override public void detachPlayer() { if (adsManager != null && imaPausedContent) { - adPlaybackState.setAdResumePositionUs(playingAd ? C.msToUs(player.getCurrentPosition()) : 0); + adPlaybackState = + adPlaybackState.withAdResumePositionUs( + playingAd ? C.msToUs(player.getCurrentPosition()) : 0); adsManager.pause(); } lastAdProgress = getAdProgress(); @@ -474,7 +477,7 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A if (DEBUG) { Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex); } - adPlaybackState.setAdCount(adGroupIndex, adCount); + adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, adCount); updateAdPlaybackState(); if (adGroupIndex != expectedAdGroupIndex) { Log.w( @@ -589,8 +592,10 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A if (DEBUG) { Log.d(TAG, "loadAd in ad group " + adGroupIndex); } - adPlaybackState.addAdUri(adGroupIndex, Uri.parse(adUriString)); - if (adPlaybackState.adsLoadedCounts[adGroupIndex] == adPlaybackState.adCounts[adGroupIndex]) { + int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex); + adPlaybackState = + adPlaybackState.withAdUri(adGroupIndex, adIndexInAdGroup, Uri.parse(adUriString)); + if (getAdIndexInAdGroupToLoad(adGroupIndex) == C.INDEX_UNSET) { // Keep track of the expected ad group index to use as a fallback if the LOADED event is // unexpectedly not triggered. expectedAdGroupIndex++; @@ -693,7 +698,7 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A long contentDurationUs = timeline.getPeriod(0, period).durationUs; contentDurationMs = C.usToMs(contentDurationUs); if (contentDurationUs != C.TIME_UNSET) { - adPlaybackState.contentDurationUs = contentDurationUs; + adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); } updateImaStateForPlayerState(); } @@ -748,7 +753,7 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A if (sentContentComplete) { for (int i = 0; i < adPlaybackState.adGroupCount; i++) { if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { - adPlaybackState.playedAdGroup(i); + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } } updateAdPlaybackState(); @@ -790,7 +795,7 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A } else /* adGroupIndexForPosition > 0 */ { // Skip ad groups before the one at or immediately before the playback position. for (int i = 0; i < adGroupIndexForPosition; i++) { - adPlaybackState.playedAdGroup(i); + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } // 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. @@ -859,7 +864,7 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A } } if (playingAd && adGroupIndex != C.INDEX_UNSET) { - adPlaybackState.playedAdGroup(adGroupIndex); + adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex); adGroupIndex = C.INDEX_UNSET; updateAdPlaybackState(); } @@ -879,7 +884,10 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A private void stopAdInternal() { Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); imaAdState = IMA_AD_STATE_NONE; - adPlaybackState.playedAd(adGroupIndex); + int adIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].nextAdIndexToPlay; + // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. + adPlaybackState = + adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); updateAdPlaybackState(); if (!playingAd) { adGroupIndex = C.INDEX_UNSET; @@ -901,7 +909,7 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A private void updateAdPlaybackState() { // Ignore updates while detached. When a player is attached it will receive the latest state. if (eventListener != null) { - eventListener.onAdPlaybackState(adPlaybackState.copy()); + eventListener.onAdPlaybackState(adPlaybackState); } } @@ -946,4 +954,19 @@ public final class ImaAdsLoader extends Player.DefaultEventListener implements A } return adGroupTimesUs; } + + /** + * Returns the next ad index in the specified ad group to load, or {@link C#INDEX_UNSET} if all + * ads in the ad group have loaded. + */ + private int getAdIndexInAdGroupToLoad(int adGroupIndex) { + @AdState int[] states = adPlaybackState.adGroups[adGroupIndex].states; + int adIndexInAdGroup = 0; + // IMA loads ads in order. + while (adIndexInAdGroup < states.length + && states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE) { + adIndexInAdGroup++; + } + return adIndexInAdGroup == states.length ? C.INDEX_UNSET : adIndexInAdGroup; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index 34547a1c25..8bc93ae243 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -326,7 +326,7 @@ import com.google.android.exoplayer2.util.Assertions; if (adGroupIndex == C.INDEX_UNSET) { return new MediaPeriodId(periodIndex); } else { - int adIndexInAdGroup = period.getPlayedAdCount(adGroupIndex); + int adIndexInAdGroup = period.getNextAdIndexToPlay(adGroupIndex); return new MediaPeriodId(periodIndex, adGroupIndex, adIndexInAdGroup); } } @@ -502,7 +502,7 @@ import com.google.android.exoplayer2.util.Assertions; .getPeriod(id.periodIndex, period) .getAdDurationUs(id.adGroupIndex, id.adIndexInAdGroup); long startPositionUs = - adIndexInAdGroup == period.getPlayedAdCount(adGroupIndex) + adIndexInAdGroup == period.getNextAdIndexToPlay(adGroupIndex) ? period.getAdResumePositionUs() : 0; return new MediaPeriodInfo( @@ -547,7 +547,7 @@ import com.google.android.exoplayer2.util.Assertions; boolean isLastAd = isAd && id.adGroupIndex == lastAdGroupIndex && id.adIndexInAdGroup == postrollAdCount - 1; - return isLastAd || (!isAd && period.getPlayedAdCount(lastAdGroupIndex) == postrollAdCount); + return isLastAd || (!isAd && period.getNextAdIndexToPlay(lastAdGroupIndex) == postrollAdCount); } private boolean isLastInTimeline(MediaPeriodId id, boolean isLastMediaPeriodInPeriod) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index 4a1f2bfd1e..26c2cc3e83 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2; import android.util.Pair; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.util.Assertions; /** @@ -278,12 +279,7 @@ public abstract class Timeline { public long durationUs; private long positionInWindowUs; - private long[] adGroupTimesUs; - private int[] adCounts; - private int[] adsLoadedCounts; - private int[] adsPlayedCounts; - private long[][] adDurationsUs; - private long adResumePositionUs; + private AdPlaybackState adPlaybackState; /** * Sets the data held by this period. @@ -300,8 +296,7 @@ public abstract class Timeline { */ public Period set(Object id, Object uid, int windowIndex, long durationUs, long positionInWindowUs) { - return set(id, uid, windowIndex, durationUs, positionInWindowUs, null, null, null, null, - null, C.TIME_UNSET); + return set(id, uid, windowIndex, durationUs, positionInWindowUs, AdPlaybackState.NONE); } /** @@ -315,33 +310,23 @@ public abstract class Timeline { * @param positionInWindowUs The position of the start of this period relative to the start of * the window to which it belongs, in milliseconds. May be negative if the start of the * period is not within the window. - * @param adGroupTimesUs The times of ad groups relative to the start of the period, in - * microseconds. A final element with the value {@link C#TIME_END_OF_SOURCE} indicates that - * the period has a postroll ad. - * @param adCounts The number of ads in each ad group. An element may be {@link C#LENGTH_UNSET} - * if the number of ads is not yet known. - * @param adsLoadedCounts The number of ads loaded so far in each ad group. - * @param adsPlayedCounts The number of ads played so far in each ad group. - * @param adDurationsUs The duration of each ad in each ad group, in microseconds. An element - * may be {@link C#TIME_UNSET} if the duration is not yet known. - * @param adResumePositionUs The position offset in the first unplayed ad at which to begin - * playback, in microseconds. + * @param adPlaybackState The state of the period's ads, or {@link AdPlaybackState#NONE} if + * there are no ads. * @return This period, for convenience. */ - public Period set(Object id, Object uid, int windowIndex, long durationUs, - long positionInWindowUs, long[] adGroupTimesUs, int[] adCounts, int[] adsLoadedCounts, - int[] adsPlayedCounts, long[][] adDurationsUs, long adResumePositionUs) { + public Period set( + Object id, + Object uid, + int windowIndex, + long durationUs, + long positionInWindowUs, + AdPlaybackState adPlaybackState) { this.id = id; this.uid = uid; this.windowIndex = windowIndex; this.durationUs = durationUs; this.positionInWindowUs = positionInWindowUs; - this.adGroupTimesUs = adGroupTimesUs; - this.adCounts = adCounts; - this.adsLoadedCounts = adsLoadedCounts; - this.adsPlayedCounts = adsPlayedCounts; - this.adDurationsUs = adDurationsUs; - this.adResumePositionUs = adResumePositionUs; + this.adPlaybackState = adPlaybackState; return this; } @@ -381,7 +366,7 @@ public abstract class Timeline { * Returns the number of ad groups in the period. */ public int getAdGroupCount() { - return adGroupTimesUs == null ? 0 : adGroupTimesUs.length; + return adPlaybackState.adGroupCount; } /** @@ -392,17 +377,19 @@ public abstract class Timeline { * @return The time of the ad group at the index, in microseconds. */ public long getAdGroupTimeUs(int adGroupIndex) { - return adGroupTimesUs[adGroupIndex]; + return adPlaybackState.adGroupTimesUs[adGroupIndex]; } /** - * Returns the number of ads that have been played in the specified ad group in the period. + * Returns the index of the next ad to play in the specified ad group, or the number of ads in + * the ad group if the ad group does not have any ads remaining to play. * * @param adGroupIndex The ad group index. - * @return The number of ads that have been played. + * @return The index of the next ad that should be played, or the number of ads in the ad group + * if the ad group does not have any ads remaining to play. */ - public int getPlayedAdCount(int adGroupIndex) { - return adsPlayedCounts[adGroupIndex]; + public int getNextAdIndexToPlay(int adGroupIndex) { + return adPlaybackState.adGroups[adGroupIndex].nextAdIndexToPlay; } /** @@ -412,8 +399,8 @@ public abstract class Timeline { * @return Whether the ad group at index {@code adGroupIndex} has been played. */ public boolean hasPlayedAdGroup(int adGroupIndex) { - return adCounts[adGroupIndex] != C.INDEX_UNSET - && adsPlayedCounts[adGroupIndex] == adCounts[adGroupIndex]; + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + return adGroup.nextAdIndexToPlay == adGroup.count; } /** @@ -425,6 +412,7 @@ public abstract class Timeline { * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexForPositionUs(long positionUs) { + long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; if (adGroupTimesUs == null) { return C.INDEX_UNSET; } @@ -446,6 +434,7 @@ public abstract class Timeline { * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexAfterPositionUs(long positionUs) { + long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; if (adGroupTimesUs == null) { return C.INDEX_UNSET; } @@ -467,7 +456,7 @@ public abstract class Timeline { * @return The number of ads in the ad group, or {@link C#LENGTH_UNSET} if not yet known. */ public int getAdCountInAdGroup(int adGroupIndex) { - return adCounts[adGroupIndex]; + return adPlaybackState.adGroups[adGroupIndex].count; } /** @@ -478,7 +467,9 @@ public abstract class Timeline { * @return Whether the URL for the specified ad is known. */ public boolean isAdAvailable(int adGroupIndex, int adIndexInAdGroup) { - return adIndexInAdGroup < adsLoadedCounts[adGroupIndex]; + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + return adGroup.count != C.LENGTH_UNSET + && adGroup.states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE; } /** @@ -490,10 +481,7 @@ public abstract class Timeline { * @return The duration of the ad, or {@link C#TIME_UNSET} if not yet known. */ public long getAdDurationUs(int adGroupIndex, int adIndexInAdGroup) { - if (adIndexInAdGroup >= adDurationsUs[adGroupIndex].length) { - return C.TIME_UNSET; - } - return adDurationsUs[adGroupIndex][adIndexInAdGroup]; + return adPlaybackState.adGroups[adGroupIndex].durationsUs[adIndexInAdGroup]; } /** @@ -501,7 +489,7 @@ public abstract class Timeline { * microseconds. */ public long getAdResumePositionUs() { - return adResumePositionUs; + return adPlaybackState.adResumePositionUs; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 58fa149b59..0bd6c9f29f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -16,49 +16,216 @@ package com.google.android.exoplayer2.source.ads; import android.net.Uri; +import android.support.annotation.CheckResult; +import android.support.annotation.IntDef; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Arrays; /** - * Represents the structure of ads to play and the state of loaded/played ads. + * Represents ad group times relative to the start of the media and information on the state and + * URIs of ads within each ad group. + * + *

Instances are immutable. Call the {@code with*} methods to get new instances that have the + * required changes. */ public final class AdPlaybackState { /** - * The number of ad groups. + * Represents a group of ads, with information about their states. + * + *

Instances are immutable. Call the {@code with*} methods to get new instances that have the + * required changes. */ + public static final class AdGroup { + + /** The number of ads in the ad group, or {@link C#LENGTH_UNSET} if unknown. */ + public final int count; + /** The URI of each ad in the ad group. */ + public final Uri[] uris; + /** The state of each ad in the ad group. */ + public final @AdState int[] states; + /** The durations of each ad in the ad group, in microseconds. */ + public final long[] durationsUs; + /** The index of the next ad that should be played, or {@link #count} if all ads were played. */ + public final int nextAdIndexToPlay; + + /** Creates a new ad group with an unspecified number of ads. */ + public AdGroup() { + this( + /* count= */ C.LENGTH_UNSET, + /* states= */ new int[0], + /* uris= */ new Uri[0], + /* durationsUs= */ new long[0]); + } + + private AdGroup(int count, @AdState int[] states, Uri[] uris, long[] durationsUs) { + Assertions.checkArgument(states.length == uris.length); + this.count = count; + this.states = states; + this.uris = uris; + this.durationsUs = durationsUs; + int nextAdIndexToPlay; + for (nextAdIndexToPlay = 0; nextAdIndexToPlay < states.length; nextAdIndexToPlay++) { + if (states[nextAdIndexToPlay] == AD_STATE_UNAVAILABLE + || states[nextAdIndexToPlay] == AD_STATE_AVAILABLE) { + break; + } + } + this.nextAdIndexToPlay = nextAdIndexToPlay; + } + + /** + * Returns a new instance with the ad count set to {@code count}. This method may only be called + * if this instance's ad count has not yet been specified. + */ + @CheckResult + public AdGroup withAdCount(int count) { + Assertions.checkArgument(this.count == C.LENGTH_UNSET && states.length <= count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, count); + long[] durationsUs = copyDurationsUsWithSpaceForAdCount(this.durationsUs, count); + Uri[] uris = Arrays.copyOf(this.uris, count); + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns a new instance with the specified {@code uri} set for the specified ad, and the ad + * marked as {@link #AD_STATE_AVAILABLE}. The specified ad must currently be in {@link + * #AD_STATE_UNAVAILABLE}, which is the default state. + * + *

This instance's ad count may be unknown, in which case {@code index} must be less than the + * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + */ + @CheckResult + public AdGroup withAdUri(Uri uri, int index) { + Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); + Assertions.checkArgument(states[index] == AD_STATE_UNAVAILABLE); + long[] durationsUs = + this.durationsUs.length == states.length + ? this.durationsUs + : copyDurationsUsWithSpaceForAdCount(this.durationsUs, states.length); + Uri[] uris = Arrays.copyOf(this.uris, states.length); + uris[index] = uri; + states[index] = AD_STATE_AVAILABLE; + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns a new instance with the specified ad set to the specified {@code state}. The ad + * specified must currently either be in {@link #AD_STATE_UNAVAILABLE} or {@link + * #AD_STATE_AVAILABLE}. + * + *

This instance's ad count may be unknown, in which case {@code index} must be less than the + * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + */ + @CheckResult + public AdGroup withAdState(@AdState int state, int index) { + Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); + Assertions.checkArgument( + states[index] == AD_STATE_UNAVAILABLE || states[index] == AD_STATE_AVAILABLE); + long[] durationsUs = + this.durationsUs.length == states.length + ? this.durationsUs + : copyDurationsUsWithSpaceForAdCount(this.durationsUs, states.length); + Uri[] uris = + this.uris.length == states.length ? this.uris : Arrays.copyOf(this.uris, states.length); + states[index] = state; + return new AdGroup(count, states, uris, durationsUs); + } + + /** Returns a new instance with the specified ad durations, in microseconds. */ + @CheckResult + public AdGroup withAdDurationsUs(long[] durationsUs) { + Assertions.checkArgument(count == C.LENGTH_UNSET || durationsUs.length <= this.uris.length); + if (durationsUs.length < this.uris.length) { + durationsUs = copyDurationsUsWithSpaceForAdCount(durationsUs, uris.length); + } + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns an instance with all unavailable and available ads marked as skipped. If the ad count + * hasn't been set, it will be set to zero. + */ + @CheckResult + public AdGroup withAllAdsSkipped() { + if (count == C.LENGTH_UNSET) { + return new AdGroup( + /* count= */ 0, + /* states= */ new int[0], + /* uris= */ new Uri[0], + /* durationsUs= */ new long[0]); + } + int count = this.states.length; + @AdState int[] states = Arrays.copyOf(this.states, count); + for (int i = 0; i < count; i++) { + if (states[i] == AD_STATE_AVAILABLE || states[i] == AD_STATE_UNAVAILABLE) { + states[i] = AD_STATE_SKIPPED; + } + } + return new AdGroup(count, states, uris, durationsUs); + } + + @CheckResult + private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) { + int oldStateCount = states.length; + int newStateCount = Math.max(count, oldStateCount); + states = Arrays.copyOf(states, newStateCount); + Arrays.fill(states, oldStateCount, newStateCount, AD_STATE_UNAVAILABLE); + return states; + } + + @CheckResult + private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int count) { + int oldDurationsUsCount = durationsUs.length; + int newDurationsUsCount = Math.max(count, oldDurationsUsCount); + durationsUs = Arrays.copyOf(durationsUs, newDurationsUsCount); + Arrays.fill(durationsUs, oldDurationsUsCount, newDurationsUsCount, C.TIME_UNSET); + return durationsUs; + } + } + + /** Represents the state of an ad in an ad group. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + AD_STATE_UNAVAILABLE, + AD_STATE_AVAILABLE, + AD_STATE_SKIPPED, + AD_STATE_PLAYED, + AD_STATE_ERROR, + }) + public @interface AdState {} + /** State for an ad that does not yet have a URL. */ + public static final int AD_STATE_UNAVAILABLE = 0; + /** State for an ad that has a URL but has not yet been played. */ + public static final int AD_STATE_AVAILABLE = 1; + /** State for an ad that was skipped. */ + public static final int AD_STATE_SKIPPED = 2; + /** State for an ad that was played in full. */ + public static final int AD_STATE_PLAYED = 3; + /** State for an ad that could not be loaded. */ + public static final int AD_STATE_ERROR = 4; + + /** Ad playback state with no ads. */ + public static final AdPlaybackState NONE = new AdPlaybackState(new long[0]); + + /** The number of ad groups. */ public final int adGroupCount; /** - * The times of ad groups, in microseconds. A final element with the value - * {@link C#TIME_END_OF_SOURCE} indicates a postroll ad. + * The times of ad groups, in microseconds. A final element with the value {@link + * C#TIME_END_OF_SOURCE} indicates a postroll ad. */ public final long[] adGroupTimesUs; - /** - * The number of ads in each ad group. An element may be {@link C#LENGTH_UNSET} if the number of - * ads is not yet known. - */ - public final int[] adCounts; - /** - * The number of ads loaded so far in each ad group. - */ - public final int[] adsLoadedCounts; - /** - * The number of ads played so far in each ad group. - */ - public final int[] adsPlayedCounts; - /** - * The URI of each ad in each ad group. - */ - public final Uri[][] adUris; - - /** - * The content duration in microseconds, if known. {@link C#TIME_UNSET} otherwise. - */ - public long contentDurationUs; - /** - * The position offset in the first unplayed ad at which to begin playback, in microseconds. - */ - public long adResumePositionUs; + /** The ad groups. */ + public final AdGroup[] adGroups; + /** The position offset in the first unplayed ad at which to begin playback, in microseconds. */ + public final long adResumePositionUs; + /** The content duration in microseconds, if known. {@link C#TIME_UNSET} otherwise. */ + public final long contentDurationUs; /** * Creates a new ad playback state with the specified ad group times. @@ -67,84 +234,104 @@ public final class AdPlaybackState { * {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad. */ public AdPlaybackState(long[] adGroupTimesUs) { - this.adGroupTimesUs = adGroupTimesUs; - adGroupCount = adGroupTimesUs.length; - adsPlayedCounts = new int[adGroupCount]; - adCounts = new int[adGroupCount]; - Arrays.fill(adCounts, C.LENGTH_UNSET); - adUris = new Uri[adGroupCount][]; - Arrays.fill(adUris, new Uri[0]); - adsLoadedCounts = new int[adGroupTimesUs.length]; + int count = adGroupTimesUs.length; + adGroupCount = count; + this.adGroupTimesUs = Arrays.copyOf(adGroupTimesUs, count); + this.adGroups = new AdGroup[count]; + for (int i = 0; i < count; i++) { + adGroups[i] = new AdGroup(); + } + adResumePositionUs = 0; contentDurationUs = C.TIME_UNSET; } - private AdPlaybackState(long[] adGroupTimesUs, int[] adCounts, int[] adsLoadedCounts, - int[] adsPlayedCounts, Uri[][] adUris, long contentDurationUs, long adResumePositionUs) { + private AdPlaybackState( + long[] adGroupTimesUs, AdGroup[] adGroups, long adResumePositionUs, long contentDurationUs) { + adGroupCount = adGroups.length; this.adGroupTimesUs = adGroupTimesUs; - this.adCounts = adCounts; - this.adsLoadedCounts = adsLoadedCounts; - this.adsPlayedCounts = adsPlayedCounts; - this.adUris = adUris; + this.adGroups = adGroups; + this.adResumePositionUs = adResumePositionUs; this.contentDurationUs = contentDurationUs; - this.adResumePositionUs = adResumePositionUs; - adGroupCount = adGroupTimesUs.length; } /** - * Returns a deep copy of this instance. + * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. + * The ad count must be greater than zero. */ - public AdPlaybackState copy() { - Uri[][] adUris = new Uri[adGroupTimesUs.length][]; - for (int i = 0; i < this.adUris.length; i++) { - adUris[i] = Arrays.copyOf(this.adUris[i], this.adUris[i].length); + @CheckResult + public AdPlaybackState withAdCount(int adGroupIndex, int adCount) { + Assertions.checkArgument(adCount > 0); + if (adGroups[adGroupIndex].count == adCount) { + return this; } - return new AdPlaybackState(Arrays.copyOf(adGroupTimesUs, adGroupCount), - Arrays.copyOf(adCounts, adGroupCount), Arrays.copyOf(adsLoadedCounts, adGroupCount), - Arrays.copyOf(adsPlayedCounts, adGroupCount), adUris, contentDurationUs, - adResumePositionUs); + AdGroup[] adGroups = Arrays.copyOf(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = this.adGroups[adGroupIndex].withAdCount(adCount); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad URI. */ + @CheckResult + public AdPlaybackState withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri) { + AdGroup[] adGroups = Arrays.copyOf(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdUri(uri, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as played. */ + @CheckResult + public AdPlaybackState withPlayedAd(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Arrays.copyOf(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_PLAYED, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as having a load error. */ + @CheckResult + public AdPlaybackState withAdLoadError(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Arrays.copyOf(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_ERROR, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** - * Sets the number of ads in the specified ad group. + * Returns an instance with all ads in the specified ad group skipped (except for those already + * marked as played or in the error state). */ - public void setAdCount(int adGroupIndex, int adCount) { - adCounts[adGroupIndex] = adCount; + @CheckResult + public AdPlaybackState withSkippedAdGroup(int adGroupIndex) { + AdGroup[] adGroups = Arrays.copyOf(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAllAdsSkipped(); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } - /** - * Adds an ad to the specified ad group. - */ - public void addAdUri(int adGroupIndex, Uri uri) { - int adIndexInAdGroup = adUris[adGroupIndex].length; - adUris[adGroupIndex] = Arrays.copyOf(adUris[adGroupIndex], adIndexInAdGroup + 1); - adUris[adGroupIndex][adIndexInAdGroup] = uri; - adsLoadedCounts[adGroupIndex]++; - } - - /** - * Marks the last ad in the specified ad group as played. - */ - public void playedAd(int adGroupIndex) { - adResumePositionUs = 0; - adsPlayedCounts[adGroupIndex]++; - } - - /** - * Marks all ads in the specified ad group as played. - */ - public void playedAdGroup(int adGroupIndex) { - adResumePositionUs = 0; - if (adCounts[adGroupIndex] == C.LENGTH_UNSET) { - adCounts[adGroupIndex] = 0; + /** Returns an instance with the specified ad durations, in microseconds. */ + @CheckResult + public AdPlaybackState withAdDurationsUs(long[][] adDurationUs) { + AdGroup[] adGroups = Arrays.copyOf(this.adGroups, this.adGroups.length); + for (int adGroupIndex = 0; adGroupIndex < adGroupCount; adGroupIndex++) { + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdDurationsUs(adDurationUs[adGroupIndex]); } - adsPlayedCounts[adGroupIndex] = adCounts[adGroupIndex]; + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } - /** - * Sets the position offset in the first unplayed ad at which to begin playback, in microseconds. - */ - public void setAdResumePositionUs(long adResumePositionUs) { - this.adResumePositionUs = adResumePositionUs; + /** Returns an instance with the specified ad resume position, in microseconds. */ + @CheckResult + public AdPlaybackState withAdResumePositionUs(long adResumePositionUs) { + if (this.adResumePositionUs == adResumePositionUs) { + return this; + } else { + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + } + + /** Returns an instance with the specified content duration, in microseconds. */ + @CheckResult + public AdPlaybackState withContentDurationUs(long contentDurationUs) { + if (this.contentDurationUs == contentDurationUs) { + return this; + } else { + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index bf76bbf9ab..cadd293bee 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -222,7 +222,7 @@ public final class AdsMediaSource extends CompositeMediaSource { int adGroupIndex = id.adGroupIndex; int adIndexInAdGroup = id.adIndexInAdGroup; if (adGroupMediaSources[adGroupIndex].length <= adIndexInAdGroup) { - Uri adUri = adPlaybackState.adUris[id.adGroupIndex][id.adIndexInAdGroup]; + Uri adUri = adPlaybackState.adGroups[id.adGroupIndex].uris[id.adIndexInAdGroup]; MediaSource adMediaSource = adMediaSourceFactory.createMediaSource(adUri, eventHandler, eventListener); int oldAdCount = adGroupMediaSources[id.adGroupIndex].length; @@ -337,11 +337,11 @@ public final class AdsMediaSource extends CompositeMediaSource { private void maybeUpdateSourceInfo() { if (adPlaybackState != null && contentTimeline != null) { - Timeline timeline = adPlaybackState.adGroupCount == 0 ? contentTimeline - : new SinglePeriodAdTimeline(contentTimeline, adPlaybackState.adGroupTimesUs, - adPlaybackState.adCounts, adPlaybackState.adsLoadedCounts, - adPlaybackState.adsPlayedCounts, adDurationsUs, adPlaybackState.adResumePositionUs, - adPlaybackState.contentDurationUs); + adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); + Timeline timeline = + adPlaybackState.adGroupCount == 0 + ? contentTimeline + : new SinglePeriodAdTimeline(contentTimeline, adPlaybackState); listener.onSourceInfoRefreshed(this, timeline, contentManifest); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java index 0a04c9ab4b..ec0d6cb2fe 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java @@ -25,54 +25,32 @@ import com.google.android.exoplayer2.util.Assertions; */ /* package */ final class SinglePeriodAdTimeline extends ForwardingTimeline { - private final long[] adGroupTimesUs; - private final int[] adCounts; - private final int[] adsLoadedCounts; - private final int[] adsPlayedCounts; - private final long[][] adDurationsUs; - private final long adResumePositionUs; - private final long contentDurationUs; + private final AdPlaybackState adPlaybackState; /** - * Creates a new timeline with a single period containing the specified ads. + * Creates a new timeline with a single period containing ads. * * @param contentTimeline The timeline of the content alongside which ads will be played. It must * have one window and one period. - * @param adGroupTimesUs The times of ad groups relative to the start of the period, in - * microseconds. A final element with the value {@link C#TIME_END_OF_SOURCE} indicates that - * the period has a postroll ad. - * @param adCounts The number of ads in each ad group. An element may be {@link C#LENGTH_UNSET} - * if the number of ads is not yet known. - * @param adsLoadedCounts The number of ads loaded so far in each ad group. - * @param adsPlayedCounts The number of ads played so far in each ad group. - * @param adDurationsUs The duration of each ad in each ad group, in microseconds. An element - * may be {@link C#TIME_UNSET} if the duration is not yet known. - * @param adResumePositionUs The position offset in the earliest unplayed ad at which to begin - * playback, in microseconds. - * @param contentDurationUs The content duration in microseconds, if known. {@link C#TIME_UNSET} - * otherwise. + * @param adPlaybackState The state of the period's ads. */ - public SinglePeriodAdTimeline(Timeline contentTimeline, long[] adGroupTimesUs, int[] adCounts, - int[] adsLoadedCounts, int[] adsPlayedCounts, long[][] adDurationsUs, long adResumePositionUs, - long contentDurationUs) { + public SinglePeriodAdTimeline(Timeline contentTimeline, AdPlaybackState adPlaybackState) { super(contentTimeline); Assertions.checkState(contentTimeline.getPeriodCount() == 1); Assertions.checkState(contentTimeline.getWindowCount() == 1); - this.adGroupTimesUs = adGroupTimesUs; - this.adCounts = adCounts; - this.adsLoadedCounts = adsLoadedCounts; - this.adsPlayedCounts = adsPlayedCounts; - this.adDurationsUs = adDurationsUs; - this.adResumePositionUs = adResumePositionUs; - this.contentDurationUs = contentDurationUs; + this.adPlaybackState = adPlaybackState; } @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { timeline.getPeriod(periodIndex, period, setIds); - period.set(period.id, period.uid, period.windowIndex, period.durationUs, - period.getPositionInWindowUs(), adGroupTimesUs, adCounts, adsLoadedCounts, adsPlayedCounts, - adDurationsUs, adResumePositionUs); + period.set( + period.id, + period.uid, + period.windowIndex, + period.durationUs, + period.getPositionInWindowUs(), + adPlaybackState); return period; } @@ -81,7 +59,7 @@ import com.google.android.exoplayer2.util.Assertions; long defaultPositionProjectionUs) { window = super.getWindow(windowIndex, window, setIds, defaultPositionProjectionUs); if (window.durationUs == C.TIME_UNSET) { - window.durationUs = contentDurationUs; + window.durationUs = adPlaybackState.contentDurationUs; } return window; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java new file mode 100644 index 0000000000..95f492f17f --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2018 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.source.ads; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Unit test for {@link AdPlaybackState}. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Config.TARGET_SDK, manifest = Config.NONE) +public final class AdPlaybackStateTest { + + private static final long[] TEST_AD_GROUP_TMES_US = new long[] {0, C.msToUs(10_000)}; + private static final Uri TEST_URI = Uri.EMPTY; + + private AdPlaybackState state; + + @Before + public void setUp() { + state = new AdPlaybackState(TEST_AD_GROUP_TMES_US); + } + + @Test + public void testSetAdCount() { + assertThat(state.adGroups[0].count).isEqualTo(C.LENGTH_UNSET); + state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); + assertThat(state.adGroups[0].count).isEqualTo(1); + } + + @Test + public void testSetAdUriBeforeAdCount() { + state = state.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1, TEST_URI); + state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2); + + assertThat(state.adGroups[0].uris[0]).isNull(); + assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.adGroups[0].uris[1]).isSameAs(TEST_URI); + assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_AVAILABLE); + } + + @Test + public void testSetAdErrorBeforeAdCount() { + state = state.withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2); + + assertThat(state.adGroups[0].uris[0]).isNull(); + assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_ERROR); + assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + } + + @Test + public void testInitialNextAdIndexToPlay() { + state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 3); + state = state.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI); + state = state.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, TEST_URI); + + assertThat(state.adGroups[0].nextAdIndexToPlay).isEqualTo(0); + } + + @Test + public void testNextAdIndexToPlayWithPlayedAd() { + state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 3); + state = state.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI); + state = state.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, TEST_URI); + + state = state.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + + assertThat(state.adGroups[0].nextAdIndexToPlay).isEqualTo(1); + assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.adGroups[0].states[2]).isEqualTo(AdPlaybackState.AD_STATE_AVAILABLE); + } + + @Test + public void testNextAdIndexToPlaySkipsErrorAds() { + state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 3); + state = state.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI); + state = state.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 2, TEST_URI); + + state = state.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + state = state.withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1); + + assertThat(state.adGroups[0].nextAdIndexToPlay).isEqualTo(2); + } + + @Test + public void testSetAdStateTwiceThrows() { + state = state.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); + state = state.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + try { + state.withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0); + fail(); + } catch (Exception e) { + // Expected. + } + } + + @Test + public void testSkipAllWithoutAdCount() { + state = state.withSkippedAdGroup(0); + state = state.withSkippedAdGroup(1); + assertThat(state.adGroups[0].count).isEqualTo(0); + assertThat(state.adGroups[1].count).isEqualTo(0); + } +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 0557a138e1..d0aa4761a4 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.testutil; import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; @@ -177,21 +178,21 @@ public final class FakeTimeline extends Timeline { } else { int adGroups = windowDefinition.adGroupsPerPeriodCount; long[] adGroupTimesUs = new long[adGroups]; - int[] adCounts = new int[adGroups]; - int[] adLoadedAndPlayedCounts = new int[adGroups]; - long[][] adDurationsUs = new long[adGroups][]; - long adResumePositionUs = 0; long adGroupOffset = adGroups > 1 ? periodDurationUs / (adGroups - 1) : 0; for (int i = 0; i < adGroups; i++) { adGroupTimesUs[i] = i * adGroupOffset; - adCounts[i] = windowDefinition.adsPerAdGroupCount; - adLoadedAndPlayedCounts[i] = 0; - adDurationsUs[i] = new long[adCounts[i]]; + } + AdPlaybackState adPlaybackState = new AdPlaybackState(adGroupTimesUs); + long[][] adDurationsUs = new long[adGroups][]; + for (int i = 0; i < adGroups; i++) { + int adCount = windowDefinition.adsPerAdGroupCount; + adPlaybackState = adPlaybackState.withAdCount(i, adCount); + adDurationsUs[i] = new long[adCount]; Arrays.fill(adDurationsUs[i], AD_DURATION_US); } - return period.set(id, uid, windowIndex, periodDurationUs, positionInWindowUs, adGroupTimesUs, - adCounts, adLoadedAndPlayedCounts, adLoadedAndPlayedCounts, adDurationsUs, - adResumePositionUs); + adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); + return period.set( + id, uid, windowIndex, periodDurationUs, positionInWindowUs, adPlaybackState); } }