Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions demos/main/src/main/assets/media.exolist.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,11 @@
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
"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="
},
{
"name": "MPD VMAP pre-roll single ad, mid-roll standard pod with 3 ads, post-roll single ad",
"uri": "https://dash.akamaized.net/dash264/TestCases/5a/nomor/1.mpd",
"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="
},
{
"name": "VMAP pre-roll single ad, mid-roll optimized pod with 3 ads, post-roll single ad",
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3670,6 +3670,10 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
periodIdWithAds.nextAdGroupIndex == C.INDEX_UNSET
|| (oldPeriodId.nextAdGroupIndex != C.INDEX_UNSET
&& periodIdWithAds.nextAdGroupIndex >= oldPeriodId.nextAdGroupIndex);
boolean isOldCuePointWithinNewPeriod =
isOldCuePointWithinNewPeriod(
timeline.getPeriodByUid(newPeriodUid, period),
oldPeriodId.nextAdGroupIndex);
// Drop update if we keep playing the same content (MediaPeriod.periodUid are identical) and
// the only change is that MediaPeriodId.nextAdGroupIndex increased. This postpones a potential
// discontinuity until we reach the former next ad group position.
Expand All @@ -3678,6 +3682,7 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
sameOldAndNewPeriodUid
&& !oldPeriodId.isAd()
&& !periodIdWithAds.isAd()
&& isOldCuePointWithinNewPeriod
&& earliestCuePointIsUnchangedOrLater;
// Drop update if the change is from/to server-side inserted ads at the same content position to
// avoid any unintentional renderer reset.
Expand Down Expand Up @@ -3791,6 +3796,17 @@ private static boolean isUsingPlaceholderPeriod(
return timeline.isEmpty() || timeline.getPeriodByUid(periodId.periodUid, period).isPlaceholder;
}

private static boolean isOldCuePointWithinNewPeriod(
Timeline.Period newPeriod, int oldNextAdGroupIndex) {
if (oldNextAdGroupIndex == C.INDEX_UNSET) {
return true;
}
AdGroup newAdGroupAtOldIndex = newPeriod
.adPlaybackState.getAdGroup(oldNextAdGroupIndex);
return newAdGroupAtOldIndex.timeUs <= newPeriod.durationUs
|| newPeriod.durationUs == C.TIME_UNSET;
}

/**
* Updates the {@link #isRebuffering} state and the timestamp of the last rebuffering event.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright (C) 2026 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 androidx.media3.exoplayer.source.ads;

import androidx.annotation.VisibleForTesting;
import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.C;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions;
import androidx.media3.exoplayer.source.ForwardingTimeline;


/**
* A custom {@link Timeline} for sources that have {@link AdPlaybackState} split among multiple periods.
* <br/>
* For each period a modified {@link AdPlaybackState} is created for each period:
* <ul>
* <li> ad group time is offset relative to period start time </li>
* <li> post-roll ad group is kept only for last period </li>
* <li> ad group count and indices are kept unchanged </li>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is surprising to me because it sounds like we would play all ads repeatedly for all periods. I assume some of the internal player logic prevents that from happening, but it would be much cleaner if we could actually split the AdPlaybackState fully into the individual ads for each period.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second note: When looking for other adjustments that might be needed in AdsMediaSource, I realized you avoided making these adjustments by keeping the adGroupIndex stable across all periods. So if you follow my suggestion here to split the AdPlaybackState properly, the logic in AdsMediaSource.createPeriod/releasePeriod/onChildSourceInfoRefreshed and potentially other places would need to change to reverse the mapping from adGroupIndex in a period to adGroupIndex in the 'global' AdPlaybackState.

Given all that, I think your approach of keeping the indices stable without further mapping is actually quite useful. I'm curious to hear whether you tried the other idea of splitting the state further still.

Copy link
Author

@kotucz kotucz Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ad states are updated in the main AdPlaybackState in AdsLoader. On every update it is being split into the multiple AdPlaybackStates with the actual states (e.g. played/skipped). The child AdPlaybackStates are not updated individually.

I'm curious to hear whether you tried the other idea of splitting the state further still.

In development we tried this. We have tried where period 0 AdPlaybackState had only preroll (or empty ad group).
Before content manifest is resolved, MaskingMediaSource provides placeholder Timeline.
At this point, the timeline has single period and so SinglePeriodAdTimeline is used (to minimize code changes impact on single period content). As a result, fist period has full AdPlaybackState and nextAdGroup is 1.

After content manifest is resolved, MultiPeriodAdTimeline is used and now 0th period has single empty pre-roll ad group. No ad group with index 1 results in AIOOB exception.

java.lang.ArrayIndexOutOfBoundsException: length=1; index=1
	at com.google.android.exoplayer2.source.ads.AdPlaybackState.getAdGroup(AdPlaybackState.java:507)
	at com.google.android.exoplayer2.Timeline$Period.getAdGroupTimeUs(Timeline.java:728)
	at com.google.android.exoplayer2.MediaPeriodQueue.getUpdatedMediaPeriodInfo(MediaPeriodQueue.java:413)
	at com.google.android.exoplayer2.MediaPeriodQueue.updateQueuedPeriods(MediaPeriodQueue.java:350)
	at com.google.android.exoplayer2.ExoPlayerImplInternal.handleMediaSourceListInfoRefreshed(ExoPlayerImplInternal.java:1824)
	at com.google.android.exoplayer2.ExoPlayerImplInternal.mediaSourceListUpdateRequestedInternal(ExoPlayerImplInternal.java:742)
	at com.google.android.exoplayer2.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:538)
	at android.os.Handler.dispatchMessage(Handler.java:102)
	at android.os.Looper.loopOnce(Looper.java:201)
	at android.os.Looper.loop(Looper.java:288)
	at android.os.HandlerThread.run(HandlerThread.java:67)

I think there would be more issues, like you described a lot of changes. So I think this was easier approach.

* </ul>
*/
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public final class AdTimeline extends ForwardingTimeline {

private final AdPlaybackState[] adPlaybackStates;

/**
* Creates a new timeline with a single period containing ads.
*
* @param contentTimeline The timeline of the content alongside which ads will be played.
* @param adPlaybackState The state of the media's ads.
*/
public AdTimeline(Timeline contentTimeline, AdPlaybackState adPlaybackState) {
super(contentTimeline);
final int periodCount = contentTimeline.getPeriodCount();
Assertions.checkState(contentTimeline.getWindowCount() == 1);
this.adPlaybackStates = new AdPlaybackState[periodCount];

final Timeline.Period period = new Timeline.Period();
for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) {
timeline.getPeriod(periodIndex, period);
adPlaybackStates[periodIndex] = forPeriod(adPlaybackState, period.positionInWindowUs,
periodIndex == periodCount - 1);
}
}

@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(),
adPlaybackStates[periodIndex],
period.isPlaceholder);
return period;
}

/**
* @param adPlaybackState original state is immutable always new modified copy is created
* @param periodStartOffsetUs period start time offset from start of timeline (microseconds)
* @param isLastPeriod true if this is the last period
* @return adPlaybackState modified for period
*/
private AdPlaybackState forPeriod(
AdPlaybackState adPlaybackState,
long periodStartOffsetUs,
boolean isLastPeriod) {
for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
final long adGroupTimeUs = adPlaybackState.getAdGroup(adGroupIndex).timeUs;
if (adGroupTimeUs == C.TIME_END_OF_SOURCE) {
if (!isLastPeriod) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex);
}
} else {
// start time relative to period start
adPlaybackState = adPlaybackState.withAdGroupTimeUs(adGroupIndex,
adGroupTimeUs - periodStartOffsetUs);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is negative or beyond the period duration, should it also be marked as "skipped" to more clearly signal that this ad group should not be played for this period?

Copy link
Author

@kotucz kotucz Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We kept it intentionally. This ensures ads play across period boundaries. As if the content was single period.
E.g. if user seeks to credits period in the end. The last midroll should still play, even if it is in the content period.

}
}
return adPlaybackState;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ protected void onChildSourceInfoRefreshed(
.handleSourceInfoRefresh(newTimeline);
maybeUpdateSourceInfo();
} else {
checkArgument(newTimeline.getPeriodCount() == 1);
// checkArgument(newTimeline.getPeriodCount() == 1);
contentTimeline = newTimeline;
mainHandler.post(
() -> {
Expand Down Expand Up @@ -510,7 +510,11 @@ private void maybeUpdateSourceInfo() {
refreshSourceInfo(contentTimeline);
} else {
adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurationsUs());
refreshSourceInfo(new SinglePeriodAdTimeline(contentTimeline, adPlaybackState));
if (contentTimeline.getPeriodCount() == 1) {
refreshSourceInfo(new SinglePeriodAdTimeline(contentTimeline, adPlaybackState));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SinglePeriodAdTimeline now sounds like a subset of the MultiPeriodAdTimelime - could this just be changed to be simply an AdTimeline? We likely need to keep SinglePeriodAdTimeline as deprecated to avoid breakages, but otherwise it looks like the new class can be just be used instead of the old one.

Copy link
Author

@kotucz kotucz Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to AdTimeline. I think it behaves same as SinglePeriodAdTimeline for the case when period count == 1. But still rather kept it for compatibility.

} else {
refreshSourceInfo(new AdTimeline(contentTimeline, adPlaybackState));
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package androidx.media3.exoplayer.source.ads;

import static androidx.media3.common.C.INDEX_UNSET;
import static androidx.media3.common.C.MICROS_PER_SECOND;
import static androidx.media3.common.C.TIME_END_OF_SOURCE;
import static androidx.media3.test.utils.FakeTimeline.FAKE_MEDIA_ITEM;
import static org.junit.Assert.assertEquals;

import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.Timeline.Period;
import androidx.media3.test.utils.FakeTimeline;
import org.junit.Test;

public class AdTimelineTest {
@Test
public void getPeriod() {
String windowId = "windowId";

FakeTimeline contentTimeline = new FakeTimeline(
new FakeTimeline.TimelineWindowDefinition(
3, // periodCount
windowId,
true, // isSeekable
false, // isDynamic
false, // isLive
false, // isPlaceholder
60 * MICROS_PER_SECOND, // durationUs
0, // defaultPositionUs
0, // windowOffsetInFirstPeriodUs
AdPlaybackState.NONE, // adPlaybackState
FAKE_MEDIA_ITEM // mediaItem
)
);

AdTimeline adTimeline = new AdTimeline(
contentTimeline, new AdPlaybackState(
"adsId",
0L, 10 * MICROS_PER_SECOND, // period 0: 0s - 20s
25 * MICROS_PER_SECOND, 35 * MICROS_PER_SECOND, // period 1: 20s - 40s
45 * MICROS_PER_SECOND, 55 * MICROS_PER_SECOND // period 2: 40s - 60s
));

Period period0 = new Period();
adTimeline.getPeriod(0, period0);

// period durations are uniformly split windowDuration/periodCount
assertEquals(20 * MICROS_PER_SECOND, period0.durationUs);

// positions within the 0th period
assertEquals(0, period0.getAdGroupIndexForPositionUs(1 * MICROS_PER_SECOND));
assertEquals(1, period0.getAdGroupIndexAfterPositionUs(1 * MICROS_PER_SECOND));
assertEquals(1, period0.getAdGroupIndexForPositionUs(19 * MICROS_PER_SECOND));
// no more ads to be played in 0th period
assertEquals(INDEX_UNSET, period0.getAdGroupIndexAfterPositionUs(19 * MICROS_PER_SECOND));

Period period1 = new Period();
adTimeline.getPeriod(1, period1);

// positions within the 1st period
assertEquals(1, period1.getAdGroupIndexForPositionUs(1 * MICROS_PER_SECOND)); // 21s
assertEquals(2, period1.getAdGroupIndexAfterPositionUs(1 * MICROS_PER_SECOND)); // 21s
assertEquals(2, period1.getAdGroupIndexForPositionUs(10 * MICROS_PER_SECOND)); // 30s
assertEquals(3, period1.getAdGroupIndexAfterPositionUs(10 * MICROS_PER_SECOND)); // 30s
assertEquals(3, period1.getAdGroupIndexForPositionUs(19 * MICROS_PER_SECOND)); // 39s
// no more ads to be played in 1st period
assertEquals(INDEX_UNSET, period1.getAdGroupIndexAfterPositionUs(19 * MICROS_PER_SECOND)); // 39s

Period period2 = new Period();
adTimeline.getPeriod(2, period2);

// positions within the 2nd period
assertEquals(3, period2.getAdGroupIndexForPositionUs(1 * MICROS_PER_SECOND)); // 41s
assertEquals(4, period2.getAdGroupIndexAfterPositionUs(1 * MICROS_PER_SECOND)); // 41s
assertEquals(4, period2.getAdGroupIndexForPositionUs(10 * MICROS_PER_SECOND)); // 50s
assertEquals(5, period2.getAdGroupIndexAfterPositionUs(10 * MICROS_PER_SECOND)); // 50s
assertEquals(5, period2.getAdGroupIndexForPositionUs(19 * MICROS_PER_SECOND)); // 59s
// no more ads to be played in 2nd period
assertEquals(INDEX_UNSET, period2.getAdGroupIndexAfterPositionUs(19 * MICROS_PER_SECOND)); // 59s
}

@Test
public void getPeriod_postRoll() {
String windowId = "windowId";

FakeTimeline contentTimeline = new FakeTimeline(
new FakeTimeline.TimelineWindowDefinition(
2, // periodCount
windowId,
true, // isSeekable
false, // isDynamic
false, // isLive
false, // isPlaceholder
60 * MICROS_PER_SECOND, // durationUs
0, // defaultPositionUs
0, // windowOffsetInFirstPeriodUs
AdPlaybackState.NONE, // adPlaybackState
FAKE_MEDIA_ITEM // mediaItem
)
);

AdTimeline adTimeline = new AdTimeline(
contentTimeline, new AdPlaybackState(
"adsId",
TIME_END_OF_SOURCE // period 1: 30s - 60s
));

Period period0 = new Period();
adTimeline.getPeriod(0, period0);

// period durations are uniformly split windowDuration/periodCount
assertEquals(30 * MICROS_PER_SECOND, period0.durationUs);

assertEquals(INDEX_UNSET, period0.getAdGroupIndexForPositionUs(15 * MICROS_PER_SECOND));
// post-roll should not be played in 0th period
assertEquals(INDEX_UNSET, period0.getAdGroupIndexAfterPositionUs(15 * MICROS_PER_SECOND));

Period period1 = new Period();
adTimeline.getPeriod(1, period1);

assertEquals(INDEX_UNSET, period1.getAdGroupIndexForPositionUs(29 * MICROS_PER_SECOND)); // 59s
// post-roll in the end
assertEquals(0, period1.getAdGroupIndexAfterPositionUs(29 * MICROS_PER_SECOND)); // 59s
}
}