Commit ef5a0b6c by tonihei Committed by Oliver Woodman

Allow ad groups to specify a resume offset.

Content after ad groups currently always resumes at the ad break position (unless
overridden by a seek or similar).  In some cases, media inserting ads wants to
specify an offset after the ad group at which playback should resume. A common
example is a live stream that inserts an ad and then wants to continue streaming
at the current live edge.

Support this use case by allowing ad groups to specify a content resume offset
and making sure that the content start position after the ad group uses this offset.

PiperOrigin-RevId: 373393807
parent a038f875
......@@ -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.
......
......@@ -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) {
......
......@@ -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) {
......
......@@ -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);
}
......
......@@ -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);
}
}
......@@ -205,6 +205,65 @@ public final class MediaPeriodQueueTest {
}
@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);
assertGetNextMediaPeriodInfoReturnsContentMediaPeriod(
......
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