-
Notifications
You must be signed in to change notification settings - Fork 765
Multi period content support for AdsMediaSource #3126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
120a4d0
e5c9cd9
94a0719
016a4fa
9441838
f686ed6
4ab8dc3
ceab4f8
f7adf51
b3e9330
ab368c4
ca0fe5b
244311b
efce104
ef36d82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| * </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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
| } | ||
| return adPlaybackState; | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renamed to |
||
| } else { | ||
| refreshSourceInfo(new AdTimeline(contentTimeline, 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 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 theadGroupIndexstable across all periods. So if you follow my suggestion here to split theAdPlaybackStateproperly, the logic inAdsMediaSource.createPeriod/releasePeriod/onChildSourceInfoRefreshedand potentially other places would need to change to reverse the mapping fromadGroupIndexin a period toadGroupIndexin 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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
AdPlaybackStateinAdsLoader. On every update it is being split into the multipleAdPlaybackStates with the actual states (e.g. played/skipped). The childAdPlaybackStates are not updated individually.In development we tried this. We have tried where period 0
AdPlaybackStatehad only preroll (or empty ad group).Before content manifest is resolved,
MaskingMediaSourceprovides placeholderTimeline.At this point, the timeline has single period and so
SinglePeriodAdTimelineis used (to minimize code changes impact on single period content). As a result, fist period has fullAdPlaybackStateand nextAdGroup is 1.After content manifest is resolved,
MultiPeriodAdTimelineis used and now 0th period has single empty pre-roll ad group. No ad group with index 1 results in AIOOB exception.I think there would be more issues, like you described a lot of changes. So I think this was easier approach.