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: + * + */ +@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 + } +}