mirror of
https://github.com/samsonjs/media.git
synced 2026-04-13 12:35:48 +00:00
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
This commit is contained in:
parent
a038f875f6
commit
ef5a0b6c4d
6 changed files with 150 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue