diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1456657629..a7505e8f69 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -41,6 +41,7 @@ * Ad playback: * Support changing ad break positions in the player logic ([#5067](https://github.com/google/ExoPlayer/issues/5067). + * Support resuming content with an offset after an ad group. * HLS * Use the PRECISE attribute in EXT-X-START to select the default start position. diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java index 082cdf494b..5730f551be 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -804,6 +804,17 @@ public abstract class Timeline implements Bundleable { return adPlaybackState.adResumePositionUs; } + /** + * Returns the offset in microseconds which should be added to the content stream when resuming + * playback after the specified ad group. + * + * @param adGroupIndex The ad group index. + * @return The offset that should be added to the content stream, in microseconds. + */ + public long getContentResumeOffsetUs(int adGroupIndex) { + return adPlaybackState.adGroups[adGroupIndex].contentResumeOffsetUs; + } + @Override public boolean equals(@Nullable Object obj) { if (this == obj) { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 160cc1fa1a..c3be231209 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -58,6 +58,11 @@ public final class AdPlaybackState implements Bundleable { @AdState public final int[] states; /** The durations of each ad in the ad group, in microseconds. */ public final long[] durationsUs; + /** + * The offset in microseconds which should be added to the content stream when resuming playback + * after the ad group. + */ + public final long contentResumeOffsetUs; /** Creates a new ad group with an unspecified number of ads. */ public AdGroup() { @@ -65,16 +70,22 @@ public final class AdPlaybackState implements Bundleable { /* count= */ C.LENGTH_UNSET, /* states= */ new int[0], /* uris= */ new Uri[0], - /* durationsUs= */ new long[0]); + /* durationsUs= */ new long[0], + /* contentResumeOffsetUs= */ 0); } private AdGroup( - int count, @AdState int[] states, @NullableType Uri[] uris, long[] durationsUs) { + int count, + @AdState int[] states, + @NullableType Uri[] uris, + long[] durationsUs, + long contentResumeOffsetUs) { checkArgument(states.length == uris.length); this.count = count; this.states = states; this.uris = uris; this.durationsUs = durationsUs; + this.contentResumeOffsetUs = contentResumeOffsetUs; } /** @@ -118,7 +129,8 @@ public final class AdPlaybackState implements Bundleable { return count == adGroup.count && Arrays.equals(uris, adGroup.uris) && Arrays.equals(states, adGroup.states) - && Arrays.equals(durationsUs, adGroup.durationsUs); + && Arrays.equals(durationsUs, adGroup.durationsUs) + && contentResumeOffsetUs == adGroup.contentResumeOffsetUs; } @Override @@ -127,6 +139,7 @@ public final class AdPlaybackState implements Bundleable { result = 31 * result + Arrays.hashCode(uris); result = 31 * result + Arrays.hashCode(states); result = 31 * result + Arrays.hashCode(durationsUs); + result = 31 * result + (int) (contentResumeOffsetUs ^ (contentResumeOffsetUs >>> 32)); return result; } @@ -136,7 +149,7 @@ public final class AdPlaybackState implements Bundleable { @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, count); long[] durationsUs = copyDurationsUsWithSpaceForAdCount(this.durationsUs, count); @NullableType Uri[] uris = Arrays.copyOf(this.uris, count); - return new AdGroup(count, states, uris, durationsUs); + return new AdGroup(count, states, uris, durationsUs, contentResumeOffsetUs); } /** @@ -153,7 +166,7 @@ public final class AdPlaybackState implements Bundleable { @NullableType Uri[] uris = Arrays.copyOf(this.uris, states.length); uris[index] = uri; states[index] = AD_STATE_AVAILABLE; - return new AdGroup(count, states, uris, durationsUs); + return new AdGroup(count, states, uris, durationsUs, contentResumeOffsetUs); } /** @@ -180,7 +193,7 @@ public final class AdPlaybackState implements Bundleable { 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); + return new AdGroup(count, states, uris, durationsUs, contentResumeOffsetUs); } /** Returns a new instance with the specified ad durations, in microseconds. */ @@ -191,7 +204,13 @@ public final class AdPlaybackState implements Bundleable { } else if (count != C.LENGTH_UNSET && durationsUs.length > uris.length) { durationsUs = Arrays.copyOf(durationsUs, uris.length); } - return new AdGroup(count, states, uris, durationsUs); + return new AdGroup(count, states, uris, durationsUs, contentResumeOffsetUs); + } + + /** Returns an instance with the specified {@link #contentResumeOffsetUs}. */ + @CheckResult + public AdGroup withContentResumeOffsetUs(long contentResumeOffsetUs) { + return new AdGroup(count, states, uris, durationsUs, contentResumeOffsetUs); } /** @@ -205,7 +224,8 @@ public final class AdPlaybackState implements Bundleable { /* count= */ 0, /* states= */ new int[0], /* uris= */ new Uri[0], - /* durationsUs= */ new long[0]); + /* durationsUs= */ new long[0], + contentResumeOffsetUs); } int count = this.states.length; @AdState int[] states = Arrays.copyOf(this.states, count); @@ -214,7 +234,7 @@ public final class AdPlaybackState implements Bundleable { states[i] = AD_STATE_SKIPPED; } } - return new AdGroup(count, states, uris, durationsUs); + return new AdGroup(count, states, uris, durationsUs, contentResumeOffsetUs); } @CheckResult @@ -239,13 +259,20 @@ public final class AdPlaybackState implements Bundleable { @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({FIELD_COUNT, FIELD_URIS, FIELD_STATES, FIELD_DURATIONS_US}) + @IntDef({ + FIELD_COUNT, + FIELD_URIS, + FIELD_STATES, + FIELD_DURATIONS_US, + FIELD_CONTENT_RESUME_OFFSET_US, + }) private @interface FieldNumber {} private static final int FIELD_COUNT = 0; private static final int FIELD_URIS = 1; private static final int FIELD_STATES = 2; private static final int FIELD_DURATIONS_US = 3; + private static final int FIELD_CONTENT_RESUME_OFFSET_US = 4; // putParcelableArrayList actually supports null elements. @SuppressWarnings("nullness:argument.type.incompatible") @@ -257,6 +284,7 @@ public final class AdPlaybackState implements Bundleable { keyForField(FIELD_URIS), new ArrayList<@NullableType Uri>(Arrays.asList(uris))); bundle.putIntArray(keyForField(FIELD_STATES), states); bundle.putLongArray(keyForField(FIELD_DURATIONS_US), durationsUs); + bundle.putLong(keyForField(FIELD_CONTENT_RESUME_OFFSET_US), contentResumeOffsetUs); return bundle; } @@ -273,11 +301,13 @@ public final class AdPlaybackState implements Bundleable { @AdState int[] states = bundle.getIntArray(keyForField(FIELD_STATES)); @Nullable long[] durationsUs = bundle.getLongArray(keyForField(FIELD_DURATIONS_US)); + long contentResumeOffsetUs = bundle.getLong(keyForField(FIELD_CONTENT_RESUME_OFFSET_US)); return new AdGroup( count, states == null ? new int[0] : states, uriList == null ? new Uri[0] : uriList.toArray(new Uri[0]), - durationsUs == null ? new long[0] : durationsUs); + durationsUs == null ? new long[0] : durationsUs, + contentResumeOffsetUs); } private static String keyForField(@AdGroup.FieldNumber int field) { @@ -559,6 +589,22 @@ public final class AdPlaybackState implements Bundleable { } } + /** + * Returns an instance with the specified {@link AdGroup#contentResumeOffsetUs}, in microseconds, + * for the specified ad group. + */ + @CheckResult + public AdPlaybackState withContentResumeOffsetUs(int adGroupIndex, long contentResumeOffsetUs) { + if (adGroups[adGroupIndex].contentResumeOffsetUs == contentResumeOffsetUs) { + return this; + } + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = + adGroups[adGroupIndex].withContentResumeOffsetUs(contentResumeOffsetUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + @Override public boolean equals(@Nullable Object o) { if (this == o) { diff --git a/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java index 23d8ca1be1..1b8e2e9685 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -193,6 +193,8 @@ public class AdPlaybackStateTest { .withPlayedAd(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 1) .withAdUri(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, TEST_URI) .withAdUri(/* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 1, TEST_URI) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, /* contentResumeOffsetUs= */ 4444) + .withContentResumeOffsetUs(/* adGroupIndex= */ 1, /* contentResumeOffsetUs= */ 3333) .withAdDurationsUs(new long[][] {{12}, {34, 56}}) .withAdResumePositionUs(123) .withContentDurationUs(456); @@ -216,7 +218,8 @@ public class AdPlaybackStateTest { .withAdState(AD_STATE_PLAYED, /* index= */ 1) .withAdUri(Uri.parse("https://www.google.com"), /* index= */ 0) .withAdUri(Uri.EMPTY, /* index= */ 1) - .withAdDurationsUs(new long[] {1234, 5678}); + .withAdDurationsUs(new long[] {1234, 5678}) + .withContentResumeOffsetUs(4444); assertThat(AdPlaybackState.AdGroup.CREATOR.fromBundle(adGroup.toBundle())).isEqualTo(adGroup); } 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 7d01a0af0f..9691ac6cb3 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 @@ -703,10 +703,13 @@ import com.google.common.collect.ImmutableList; } startPositionUs = defaultPosition.second; } + long minStartPositionUs = + getMinStartPositionAfterAdGroupUs( + timeline, currentPeriodId.periodUid, currentPeriodId.adGroupIndex); return getMediaPeriodInfoForContent( timeline, currentPeriodId.periodUid, - startPositionUs, + max(minStartPositionUs, startPositionUs), mediaPeriodInfo.requestedContentPositionUs, currentPeriodId.windowSequenceNumber); } @@ -715,10 +718,13 @@ import com.google.common.collect.ImmutableList; int adIndexInAdGroup = period.getFirstAdIndexToPlay(currentPeriodId.nextAdGroupIndex); if (adIndexInAdGroup == period.getAdCountInAdGroup(currentPeriodId.nextAdGroupIndex)) { // The next ad group has no ads left to play. Play content from the end position instead. + long startPositionUs = + getMinStartPositionAfterAdGroupUs( + timeline, currentPeriodId.periodUid, currentPeriodId.nextAdGroupIndex); return getMediaPeriodInfoForContent( timeline, currentPeriodId.periodUid, - /* startPositionUs= */ mediaPeriodInfo.durationUs, + startPositionUs, /* requestedContentPositionUs= */ mediaPeriodInfo.durationUs, currentPeriodId.windowSequenceNumber); } @@ -842,4 +848,14 @@ import com.google.common.collect.ImmutableList; && timeline.isLastPeriod(periodIndex, period, window, repeatMode, shuffleModeEnabled) && isLastMediaPeriodInPeriod; } + + private long getMinStartPositionAfterAdGroupUs( + Timeline timeline, Object periodUid, int adGroupIndex) { + timeline.getPeriodByUid(periodUid, period); + long startPositionUs = period.getAdGroupTimeUs(adGroupIndex); + if (startPositionUs == C.TIME_END_OF_SOURCE) { + return period.durationUs; + } + return startPositionUs + period.getContentResumeOffsetUs(adGroupIndex); + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index 6d84ce039c..7af94058b5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -204,6 +204,65 @@ public final class MediaPeriodQueueTest { /* nextAdGroupIndex= */ C.INDEX_UNSET); } + @Test + public void getNextMediaPeriodInfo_withAdGroupResumeOffsets_returnsCorrectMediaPeriodInfos() { + adPlaybackState = + new AdPlaybackState( + /* adsId= */ new Object(), + /* adGroupTimesUs...= */ 0, + FIRST_AD_START_TIME_US, + C.TIME_END_OF_SOURCE) + .withContentDurationUs(CONTENT_DURATION_US) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, /* contentResumeOffsetUs= */ 2000) + .withContentResumeOffsetUs(/* adGroupIndex= */ 1, /* contentResumeOffsetUs= */ 3000) + .withContentResumeOffsetUs(/* adGroupIndex= */ 2, /* contentResumeOffsetUs= */ 4000); + SinglePeriodAdTimeline adTimeline = + new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + setupTimeline(adTimeline); + + setAdGroupLoaded(/* adGroupIndex= */ 0); + assertNextMediaPeriodInfoIsAd( + /* adGroupIndex= */ 0, AD_DURATION_US, /* contentPositionUs= */ C.TIME_UNSET); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + /* periodUid= */ firstPeriodUid, + /* startPositionUs= */ 2000, + /* requestedContentPositionUs= */ C.TIME_UNSET, + /* endPositionUs= */ FIRST_AD_START_TIME_US, + /* durationUs= */ FIRST_AD_START_TIME_US, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* nextAdGroupIndex= */ 1); + advance(); + setAdGroupLoaded(/* adGroupIndex= */ 1); + assertNextMediaPeriodInfoIsAd( + /* adGroupIndex= */ 1, AD_DURATION_US, /* contentPositionUs= */ FIRST_AD_START_TIME_US); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + /* periodUid= */ firstPeriodUid, + /* startPositionUs= */ FIRST_AD_START_TIME_US + 3000, + /* requestedContentPositionUs= */ FIRST_AD_START_TIME_US, + /* endPositionUs= */ C.TIME_END_OF_SOURCE, + /* durationUs= */ CONTENT_DURATION_US, + /* isLastInPeriod= */ false, + /* isLastInWindow= */ false, + /* nextAdGroupIndex= */ 2); + advance(); + setAdGroupLoaded(/* adGroupIndex= */ 2); + assertNextMediaPeriodInfoIsAd( + /* adGroupIndex= */ 2, AD_DURATION_US, /* contentPositionUs= */ CONTENT_DURATION_US); + advance(); + assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( + /* periodUid= */ firstPeriodUid, + /* startPositionUs= */ CONTENT_DURATION_US - 1, + /* requestedContentPositionUs= */ CONTENT_DURATION_US, + /* endPositionUs= */ C.TIME_UNSET, + /* durationUs= */ CONTENT_DURATION_US, + /* isLastInPeriod= */ true, + /* isLastInWindow= */ true, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + } + @Test public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaPeriodInfo() { setupAdTimeline(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE);