Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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,12 +3670,15 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
periodIdWithAds.nextAdGroupIndex == C.INDEX_UNSET
|| (oldPeriodId.nextAdGroupIndex != C.INDEX_UNSET
&& periodIdWithAds.nextAdGroupIndex >= oldPeriodId.nextAdGroupIndex);
final boolean samePeriodCount =
playbackInfo.timeline.getPeriodCount() == timeline.getPeriodCount();
// 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.
boolean sameOldAndNewPeriodUid = oldPeriodId.periodUid.equals(newPeriodUid);
boolean onlyNextAdGroupIndexIncreased =
sameOldAndNewPeriodUid
&& samePeriodCount
Copy link
Collaborator

Choose a reason for hiding this comment

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

This specific condition about the having the same number of periods is probably too restrictive overall (imagine using the AdsMediaSource in a playlist with other items).

Is it possible to check for Timeline.Period.isPlaceholder in this condition to not apply this check when we go from placeholder to non-placeholder? This would of course only work if the isPlaceholder flag is changing at the right moment too. Alternatively, I wonder if we need additional checks for this logic to verify that the time of the ad group hasn't changed or is within the period duration. As you can see from the comment, the purpose of this check has really nothing to do with the multi-period ads, but with failed future ads that are not supposed to interrupt playback immediately.

Copy link
Author

Choose a reason for hiding this comment

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

I had to think about this. I implemented more specific check:
If the cuepoint being removed is not part of the period anymore, need to trigger update.
The update may not be postponed as the cuepoint will not be reached before the period ends.

Note: I am bit hesitant about using period being mutable in/out argument. Are there any best practices for using this?

&& !oldPeriodId.isAd()
&& !periodIdWithAds.isAd()
&& earliestCuePointIsUnchangedOrLater;
Expand Down
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 MultiPeriodAdTimeline(contentTimeline, adPlaybackState));
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright (C) 2017 The Android Open Source Project
Copy link
Collaborator

Choose a reason for hiding this comment

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

2026 :)

*
* 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>
* </ul>
*/
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public final class MultiPeriodAdTimeline 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 MultiPeriodAdTimeline(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;
}

}
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 MultiPeriodAdTimelineTest {
@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
)
);

MultiPeriodAdTimeline multiPeriodAdTimeline = new MultiPeriodAdTimeline(
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();
multiPeriodAdTimeline.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();
multiPeriodAdTimeline.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();
multiPeriodAdTimeline.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
)
);

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

Period period0 = new Period();
multiPeriodAdTimeline.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();
multiPeriodAdTimeline.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
}
}