diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json
index ed691ced7e2..3d2b0b3cc9c 100644
--- a/demos/main/src/main/assets/media.exolist.json
+++ b/demos/main/src/main/assets/media.exolist.json
@@ -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",
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java
index 71f389d28a7..505136131b3 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java
@@ -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.
@@ -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.
@@ -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.
*
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdTimeline.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdTimeline.java
new file mode 100644
index 00000000000..eadedd00c6d
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdTimeline.java
@@ -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.
+ *
+ * For each period a modified {@link AdPlaybackState} is created for each period:
+ *
+ * - ad group time is offset relative to period start time
+ * - post-roll ad group is kept only for last period
+ * - ad group count and indices are kept unchanged
+ *
+ */
+@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);
+ }
+ }
+ return adPlaybackState;
+ }
+
+}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java
index ff03dcec567..cb0b1477933 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java
@@ -369,7 +369,7 @@ protected void onChildSourceInfoRefreshed(
.handleSourceInfoRefresh(newTimeline);
maybeUpdateSourceInfo();
} else {
- checkArgument(newTimeline.getPeriodCount() == 1);
+// checkArgument(newTimeline.getPeriodCount() == 1);
contentTimeline = newTimeline;
mainHandler.post(
() -> {
@@ -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));
+ } else {
+ refreshSourceInfo(new AdTimeline(contentTimeline, adPlaybackState));
+ }
}
}
}
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdTimelineTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdTimelineTest.java
new file mode 100644
index 00000000000..b9b4d3e0cbc
--- /dev/null
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdTimelineTest.java
@@ -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
+ }
+}