Skip to content

Commit cd4f6e4

Browse files
committed
Address Critique comments on AdTimeline and AdTagLoader
1 parent e960564 commit cd4f6e4

13 files changed

Lines changed: 335 additions & 72 deletions

File tree

RELEASENOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
count to decrease when an ad group is fully processed
1515
(`hasUnplayedAds()` is `false`), accommodating dynamic ad group resizing
1616
during reset workflows.
17+
* Add support for ads in multi-period content (e.g., DASH) by splitting
18+
and offsetting the `AdPlaybackState` for each period.
1719
* Add `getFlags()` and `FLAG_STRICT_DURATION` to `SampleStream` to allow
1820
streams to report flags, and update renderers to check these flags
1921
dynamically.

demos/main/src/main/assets/media.exolist.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,6 @@
348348
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
349349
"ad_tag_uri": "https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/ad_rule_samples&ciu_szs=300x250&ad_rule=1&impl=s&gdfp_req=1&env=vp&output=vmap&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ar%3Dpremidpostpod&cmsid=496&vid=short_onecue&correlator="
350350
},
351-
352351
{
353352
"name": "VMAP pre-roll single ad, mid-roll optimized pod with 3 ads, post-roll single ad",
354353
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",

libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4167,7 +4167,13 @@ private static boolean isOldAdGroupWithinNewPeriod(
41674167
if (oldNextAdGroupIndex == C.INDEX_UNSET) {
41684168
return true;
41694169
}
4170+
if (oldNextAdGroupIndex >= newPeriod.adPlaybackState.adGroupCount) {
4171+
return false;
4172+
}
41704173
AdGroup newAdGroupAtOldIndex = newPeriod.adPlaybackState.getAdGroup(oldNextAdGroupIndex);
4174+
if (newAdGroupAtOldIndex.timeUs == C.TIME_END_OF_SOURCE) {
4175+
return true;
4176+
}
41714177
return newAdGroupAtOldIndex.timeUs <= newPeriod.durationUs
41724178
|| newPeriod.durationUs == C.TIME_UNSET;
41734179
}

libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdTimeline.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2017 The Android Open Source Project
2+
* Copyright (C) 2026 The Android Open Source Project
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -46,7 +46,9 @@ public final class AdTimeline extends ForwardingTimeline {
4646
* Creates a new timeline alongside which ads will be played.
4747
*
4848
* @param contentTimeline The timeline of the content alongside which ads will be played.
49-
* @param adPlaybackState The state of the media's ads.
49+
* @param adPlaybackState The state of the media's ads. The ad group times in this state must be
50+
* relative to the start of the window (i.e. timeUs = 0 corresponds to the start of the
51+
* window).
5052
*/
5153
public AdTimeline(Timeline contentTimeline, AdPlaybackState adPlaybackState) {
5254
super(contentTimeline);
@@ -61,6 +63,7 @@ public AdTimeline(Timeline contentTimeline, AdPlaybackState adPlaybackState) {
6163
forPeriod(
6264
adPlaybackState,
6365
period.positionInWindowUs,
66+
period.durationUs,
6467
/* isLastPeriod= */ periodIndex == periodCount - 1);
6568
}
6669
}
@@ -84,15 +87,31 @@ public Period getPeriod(int periodIndex, Period period, boolean setIds) {
8487
}
8588

8689
private AdPlaybackState forPeriod(
87-
AdPlaybackState adPlaybackState, long periodStartOffsetUs, boolean isLastPeriod) {
90+
AdPlaybackState adPlaybackState,
91+
long periodStartOffsetUs,
92+
long periodDurationUs,
93+
boolean isLastPeriod) {
94+
if (periodStartOffsetUs == 0 && isLastPeriod) {
95+
long contentDurationUs =
96+
periodDurationUs == C.TIME_UNSET ? adPlaybackState.contentDurationUs : periodDurationUs;
97+
return adPlaybackState.withContentDurationUs(contentDurationUs);
98+
}
99+
long contentDurationUs = periodDurationUs;
100+
if (contentDurationUs == C.TIME_UNSET) {
101+
contentDurationUs =
102+
isLastPeriod && adPlaybackState.contentDurationUs != C.TIME_UNSET
103+
? adPlaybackState.contentDurationUs - periodStartOffsetUs
104+
: C.TIME_UNSET;
105+
}
106+
adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs);
107+
88108
for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
89109
long adGroupTimeUs = adPlaybackState.getAdGroup(adGroupIndex).timeUs;
90110
if (adGroupTimeUs == C.TIME_END_OF_SOURCE) {
91111
if (!isLastPeriod) {
92112
adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex);
93113
}
94114
} else {
95-
// start time relative to period start
96115
adPlaybackState =
97116
adPlaybackState.withAdGroupTimeUs(adGroupIndex, adGroupTimeUs - periodStartOffsetUs);
98117
}

libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ interface EventListener {
7676
* Called when the ad playback state has been updated. The number of {@link
7777
* AdPlaybackState#adGroupCount ad groups} may not change after the first call.
7878
*
79+
* <p>The ad group times in the {@link AdPlaybackState} must be relative to the start of the
80+
* timeline window (i.e. timeUs = 0 corresponds to the start of the window).
81+
*
7982
* @param adPlaybackState The new ad playback state.
8083
*/
8184
default void onAdPlaybackState(AdPlaybackState adPlaybackState) {}

libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@
6464
/**
6565
* A {@link MediaSource} that inserts ads linearly into a provided content media source.
6666
*
67-
* <p>The wrapped content media source must contain a single {@link Timeline.Period}.
67+
* <p>The wrapped content media source can contain multiple {@linkplain Timeline.Period periods}.
68+
* Note that multi-period ad insertion is not supported by all {@link AdsLoader} implementations
69+
* (for example, the IMA extension's {@code ImaAdsLoader} only supports single-period Timelines).
6870
*/
6971
@UnstableApi
7072
public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
@@ -153,11 +155,12 @@ public RuntimeException getRuntimeExceptionForUnexpected() {
153155
private final boolean useLazyContentSourcePreparation;
154156
private final boolean useAdMediaSourceClipping;
155157
private final List<AdMediaSourceHolder> activeMediaSourceHolders;
156-
private final Map<ClippingMediaPeriod, Integer> activeContentClippingMediaPeriods;
158+
private final Map<ClippingMediaPeriod, MediaPeriodId> activeContentClippingMediaPeriods;
157159

158160
// Accessed on the player thread.
159161
@Nullable private ComponentListener componentListener;
160162
@Nullable private Timeline contentTimeline;
163+
@Nullable private Timeline activeTimeline;
161164
@Nullable private AdPlaybackState adPlaybackState;
162165
private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders;
163166
@Nullable private Handler playerHandler;
@@ -330,14 +333,16 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star
330333
if (id.nextAdGroupIndex == C.INDEX_UNSET) {
331334
return maskingMediaPeriod;
332335
}
333-
long nextAdGroupTimeUs = adPlaybackState.getAdGroup(id.nextAdGroupIndex).timeUs;
336+
long endPositionUs =
337+
getContentClippingEndPositionUs(
338+
checkNotNull(activeTimeline), id.periodUid, id.nextAdGroupIndex);
334339
ClippingMediaPeriod clippingMediaPeriod =
335340
new ClippingMediaPeriod(
336341
maskingMediaPeriod,
337342
/* enableInitialDiscontinuity= */ true,
338343
/* startUs= */ 0,
339-
/* endUs= */ nextAdGroupTimeUs);
340-
activeContentClippingMediaPeriods.put(clippingMediaPeriod, id.nextAdGroupIndex);
344+
/* endUs= */ endPositionUs);
345+
activeContentClippingMediaPeriods.put(clippingMediaPeriod, id);
341346
return clippingMediaPeriod;
342347
}
343348
}
@@ -375,6 +380,7 @@ protected void releaseSourceInternal() {
375380
this.playerHandler = null;
376381
componentListener.stop();
377382
contentTimeline = null;
383+
activeTimeline = null;
378384
adPlaybackState = null;
379385
adMediaSourceHolders = new AdMediaSourceHolder[0][];
380386
mainHandler.post(() -> adsLoader.stop(/* adsMediaSource= */ this, componentListener));
@@ -444,14 +450,6 @@ private void onAdPlaybackState(AdPlaybackState adPlaybackState) {
444450
}
445451
}
446452
}
447-
for (Map.Entry<ClippingMediaPeriod, Integer> activeClippingPeriod :
448-
activeContentClippingMediaPeriods.entrySet()) {
449-
int nextAdGroupIndex = activeClippingPeriod.getValue();
450-
long nextAdGroupTimeUs = adPlaybackState.getAdGroup(nextAdGroupIndex).timeUs;
451-
activeClippingPeriod
452-
.getKey()
453-
.updateClipping(/* startUs= */ 0, /* endUs= */ nextAdGroupTimeUs);
454-
}
455453
}
456454
this.adPlaybackState = adPlaybackState;
457455
maybeUpdateAdMediaSources();
@@ -537,10 +535,20 @@ private void maybeUpdateSourceInfo() {
537535
@Nullable Timeline contentTimeline = this.contentTimeline;
538536
if (adPlaybackState != null && contentTimeline != null) {
539537
if (adPlaybackState.adGroupCount == 0) {
538+
activeTimeline = contentTimeline;
540539
refreshSourceInfo(contentTimeline);
541540
} else {
542-
adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurationsUs());
543-
refreshSourceInfo(new AdTimeline(contentTimeline, adPlaybackState));
541+
long[][] durations = getAdDurationsUs();
542+
adPlaybackState = adPlaybackState.withAdDurationsUs(durations);
543+
activeTimeline = new AdTimeline(contentTimeline, adPlaybackState);
544+
for (Map.Entry<ClippingMediaPeriod, MediaPeriodId> activeClippingPeriod :
545+
activeContentClippingMediaPeriods.entrySet()) {
546+
MediaPeriodId id = activeClippingPeriod.getValue();
547+
long endPositionUs =
548+
getContentClippingEndPositionUs(activeTimeline, id.periodUid, id.nextAdGroupIndex);
549+
activeClippingPeriod.getKey().updateClipping(/* startUs= */ 0, endPositionUs);
550+
}
551+
refreshSourceInfo(activeTimeline);
544552
}
545553
}
546554
}
@@ -796,4 +804,14 @@ private MaskingMediaPeriod getActiveMaskingMediaPeriod(int activeMediaPeriodInde
796804
: mediaPeriod);
797805
}
798806
}
807+
808+
private long getContentClippingEndPositionUs(
809+
Timeline activeTimeline, Object periodUid, int nextAdGroupIndex) {
810+
int periodIndex = activeTimeline.getIndexOfPeriod(periodUid);
811+
if (periodIndex == C.INDEX_UNSET) {
812+
return C.TIME_END_OF_SOURCE;
813+
}
814+
activeTimeline.getPeriod(periodIndex, period);
815+
return period.adPlaybackState.getAdGroup(nextAdGroupIndex).timeUs;
816+
}
799817
}

libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerAdTest.java

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ public void playAds_midRollsWithAndWithoutDuration_clippedOrNotClippedAccordingl
346346
Timeline primaryContentTimeline =
347347
new FakeTimeline(new TimelineWindowDefinition.Builder().setDurationUs(60_000_000L).build());
348348
AdPlaybackState adPlaybackState =
349-
new AdPlaybackState("adsId", 133_000_000L, 143_000_000L)
349+
new AdPlaybackState("adsId", 10_000_000L, 20_000_000L)
350350
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
351351
.withAvailableAdMediaItem(
352352
/* adGroupIndex= */ 0,
@@ -1057,6 +1057,7 @@ public void adInMovingLiveWindow_keepsContentPosition() throws Exception {
10571057
assertThat(contentPositionAfterLiveWindowUpdateMs).isEqualTo(2000);
10581058
}
10591059

1060+
10601061
@Test
10611062
public void addMediaSource_whilePlayingAd_correctMasking() throws Exception {
10621063
long contentDurationMs = 10_000;
@@ -1879,6 +1880,7 @@ protected MediaPeriod createMediaPeriod(
18791880
player.release();
18801881
}
18811882

1883+
18821884
@Test
18831885
public void play_withPreMidAndPostRollAd_callsOnDiscontinuityCorrectly() throws Exception {
18841886
ExoPlayer player = parameterizeTestExoPlayerBuilder(new TestExoPlayerBuilder(context)).build();
@@ -2291,6 +2293,7 @@ public void play_multiItemPlaylistWidthAds_callsOnDiscontinuityCorrectly() throw
22912293
player.release();
22922294
}
22932295

2296+
22942297
@Test
22952298
public void newServerSideInsertedAdAtPlaybackPosition_keepsRenderersEnabled() throws Exception {
22962299
// Injecting renderer to count number of renderer resets.
@@ -2317,6 +2320,7 @@ public void newServerSideInsertedAdAtPlaybackPosition_keepsRenderersEnabled() th
23172320
new TimelineWindowDefinition.Builder()
23182321
.setDynamic(true)
23192322
.setDurationUs(C.TIME_UNSET)
2323+
23202324
.setAdPlaybackStates(ImmutableList.of(initialAdPlaybackState))
23212325
.build();
23222326
Timeline initialTimeline = new FakeTimeline(initialTimelineWindowDefinition);
@@ -2361,4 +2365,103 @@ public void newServerSideInsertedAdAtPlaybackPosition_keepsRenderersEnabled() th
23612365
assertThat(timeline.getPeriod(0, new Timeline.Period()).adPlaybackState.adGroupCount)
23622366
.isEqualTo(2);
23632367
}
2368+
2369+
@Test
2370+
public void playAds_midRollsInMultiPeriodContent_playedSuccessfully()
2371+
throws PlaybackException, TimeoutException {
2372+
Timeline primaryContentTimeline =
2373+
new FakeTimeline(
2374+
new TimelineWindowDefinition.Builder()
2375+
.setPeriodCount(2)
2376+
.setDurationUs(60_000_000L)
2377+
.setWindowPositionInFirstPeriodUs(0)
2378+
.build());
2379+
AdPlaybackState adPlaybackState =
2380+
new AdPlaybackState("adsId", 10_000_000L, 40_000_000L)
2381+
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
2382+
.withAvailableAdMediaItem(
2383+
/* adGroupIndex= */ 0,
2384+
/* adIndexInAdGroup= */ 0,
2385+
MediaItem.fromUri("http://example.com/ad_0_0"))
2386+
.withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1)
2387+
.withAvailableAdMediaItem(
2388+
/* adGroupIndex= */ 1,
2389+
/* adIndexInAdGroup= */ 0,
2390+
MediaItem.fromUri("http://example.com/ad_1_0"));
2391+
FakeAdsLoader fakeAdsLoader = new FakeAdsLoader();
2392+
AdsMediaSource adsMediaSource =
2393+
new AdsMediaSource(
2394+
new FakeMediaSource(primaryContentTimeline, ExoPlayerTestRunner.VIDEO_FORMAT),
2395+
new DataSpec(Uri.EMPTY),
2396+
"adsId",
2397+
/* adMediaSourceFactory= */ new FakeMediaSourceFactory(
2398+
new TimelineWindowDefinition.Builder()
2399+
.setDurationUs(10_000_000L)
2400+
.setWindowPositionInFirstPeriodUs(0)),
2401+
fakeAdsLoader,
2402+
/* adViewProvider= */ () -> null,
2403+
/* useLazyContentSourcePreparation= */ true,
2404+
/* useAdMediaSourceClipping= */ true);
2405+
ExoPlayer player = parameterizeTestExoPlayerBuilder(new TestExoPlayerBuilder(context)).build();
2406+
Player.Listener mockListener = mock(Player.Listener.class);
2407+
player.addListener(mockListener);
2408+
player.setMediaSource(adsMediaSource);
2409+
player.prepare();
2410+
advance(player)
2411+
.untilBackgroundThreadCondition(
2412+
(Supplier<Boolean>) () -> fakeAdsLoader.eventListeners.get("adsId") != null);
2413+
fakeAdsLoader.eventListeners.get("adsId").onAdPlaybackState(adPlaybackState);
2414+
2415+
player.play();
2416+
// Play past midroll 0 (at 10s) but before midroll 1 (at 40s) and period transition (at 30s)
2417+
advance(player).untilPositionAtLeast(/* mediaItemIndex= */ 0, /* positionMs= */ 15_000L);
2418+
// Mark midroll 0 as played.
2419+
adPlaybackState =
2420+
adPlaybackState.withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0);
2421+
fakeAdsLoader.eventListeners.get("adsId").onAdPlaybackState(adPlaybackState);
2422+
2423+
advance(player).untilState(Player.STATE_ENDED);
2424+
player.release();
2425+
2426+
ArgumentCaptor<PositionInfo> oldPositionsCaptor = ArgumentCaptor.forClass(PositionInfo.class);
2427+
ArgumentCaptor<PositionInfo> newPositionsCaptor = ArgumentCaptor.forClass(PositionInfo.class);
2428+
ArgumentCaptor<Integer> reasonsCaptor = ArgumentCaptor.forClass(Integer.class);
2429+
verify(mockListener, times(5))
2430+
.onPositionDiscontinuity(
2431+
oldPositionsCaptor.capture(), newPositionsCaptor.capture(), reasonsCaptor.capture());
2432+
assertThat(reasonsCaptor.getAllValues())
2433+
.containsExactly(
2434+
DISCONTINUITY_REASON_AUTO_TRANSITION, // content to midroll 0
2435+
DISCONTINUITY_REASON_AUTO_TRANSITION, // midroll 0 to content
2436+
DISCONTINUITY_REASON_AUTO_TRANSITION, // period transition
2437+
DISCONTINUITY_REASON_AUTO_TRANSITION, // content to midroll 1
2438+
DISCONTINUITY_REASON_AUTO_TRANSITION); // midroll 1 to content
2439+
2440+
List<PositionInfo> oldPositions = oldPositionsCaptor.getAllValues();
2441+
List<PositionInfo> newPositions = newPositionsCaptor.getAllValues();
2442+
2443+
// midroll 0 to content
2444+
assertThat(oldPositions.get(1).adGroupIndex).isEqualTo(0);
2445+
assertThat(oldPositions.get(1).positionMs).isEqualTo(10_000L); // ad duration
2446+
assertThat(oldPositions.get(1).contentPositionMs).isEqualTo(10_000L);
2447+
assertThat(newPositions.get(1).adGroupIndex).isEqualTo(C.INDEX_UNSET);
2448+
assertThat(newPositions.get(1).positionMs).isEqualTo(10_000L);
2449+
assertThat(newPositions.get(1).contentPositionMs).isEqualTo(10_000L);
2450+
2451+
// period transition
2452+
assertThat(oldPositions.get(2).periodIndex).isEqualTo(0);
2453+
assertThat(oldPositions.get(2).adGroupIndex).isEqualTo(C.INDEX_UNSET);
2454+
assertThat(oldPositions.get(2).positionMs).isEqualTo(30_000L);
2455+
assertThat(newPositions.get(2).periodIndex).isEqualTo(1);
2456+
assertThat(newPositions.get(2).adGroupIndex).isEqualTo(C.INDEX_UNSET);
2457+
assertThat(newPositions.get(2).positionMs).isEqualTo(30_000L);
2458+
2459+
// midroll 1 to content
2460+
assertThat(oldPositions.get(4).adGroupIndex).isEqualTo(1);
2461+
assertThat(oldPositions.get(4).positionMs).isEqualTo(10_000L); // ad duration
2462+
assertThat(oldPositions.get(4).contentPositionMs).isEqualTo(40_000L);
2463+
assertThat(newPositions.get(4).adGroupIndex).isEqualTo(C.INDEX_UNSET);
2464+
assertThat(newPositions.get(4).positionMs).isEqualTo(40_000L);
2465+
assertThat(newPositions.get(4).contentPositionMs).isEqualTo(40_000L);
2466+
}
23642467
}

0 commit comments

Comments
 (0)