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
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
count to decrease when an ad group is fully processed
(`hasUnplayedAds()` is `false`), accommodating dynamic ad group resizing
during reset workflows.
* Add support for ads in multi-period content (e.g., DASH) by splitting
and offsetting the `AdPlaybackState` for each period.
* Add `getFlags()` and `FLAG_STRICT_DURATION` to `SampleStream` to allow
streams to report flags, and update renderers to check these flags
dynamically.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4032,10 +4032,13 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
contentPositionForAdResolutionUs,
enforceAdPlaybackOnTimelineRefresh,
/* transitionsFromPlaceholderPeriod= */ isUsingPlaceholderPeriod);
boolean earliestCuePointIsUnchangedOrLater =
boolean earliestAdGroupIsUnchangedOrLater =
periodIdWithAds.nextAdGroupIndex == C.INDEX_UNSET
|| (oldPeriodId.nextAdGroupIndex != C.INDEX_UNSET
&& periodIdWithAds.nextAdGroupIndex >= oldPeriodId.nextAdGroupIndex);
boolean isOldAdGroupWithinNewPeriod =
isOldAdGroupWithinNewPeriod(
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 @@ -4044,7 +4047,8 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
sameOldAndNewPeriodUid
&& !oldPeriodId.isAd()
&& !periodIdWithAds.isAd()
&& earliestCuePointIsUnchangedOrLater;
&& isOldAdGroupWithinNewPeriod
&& earliestAdGroupIsUnchangedOrLater;
// Drop update if the change is from/to server-side inserted ads at the same content position to
// avoid any unintentional renderer reset.
boolean isInStreamAdChange =
Expand Down Expand Up @@ -4158,6 +4162,22 @@ private static boolean isUsingPlaceholderPeriod(
return timeline.isEmpty() || timeline.getPeriodByUid(periodId.periodUid, period).isPlaceholder;
}

private static boolean isOldAdGroupWithinNewPeriod(
Timeline.Period newPeriod, int oldNextAdGroupIndex) {
if (oldNextAdGroupIndex == C.INDEX_UNSET) {
return true;
}
if (oldNextAdGroupIndex >= newPeriod.adPlaybackState.adGroupCount) {
return false;
}
AdGroup newAdGroupAtOldIndex = newPeriod.adPlaybackState.getAdGroup(oldNextAdGroupIndex);
if (newAdGroupAtOldIndex.timeUs == C.TIME_END_OF_SOURCE) {
return true;
}
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,121 @@
/*
* 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 static com.google.common.base.Preconditions.checkState;

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

/**
* A custom {@link Timeline} for sources that have {@link AdPlaybackState} split among multiple
* periods.
*
* <p>For each period a modified {@link AdPlaybackState} is created:
*
* <ul>
* <li>Ad group time is offset relative to period start time
* <li>Post-roll ad group is kept only for last period
* <li>Ad group count and indices are kept unchanged
* </ul>
*/
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
@UnstableApi
public final class AdTimeline extends ForwardingTimeline {

private final AdPlaybackState[] adPlaybackStates;

/**
* Creates a new timeline alongside which ads will be played.
*
* @param contentTimeline The timeline of the content alongside which ads will be played.
* @param adPlaybackState The state of the media's ads. The ad group times in this state must be
* relative to the start of the window (i.e. timeUs = 0 corresponds to the start of the
* window).
*/
public AdTimeline(Timeline contentTimeline, AdPlaybackState adPlaybackState) {
super(contentTimeline);
int periodCount = contentTimeline.getPeriodCount();
checkState(contentTimeline.getWindowCount() == 1);
this.adPlaybackStates = new AdPlaybackState[periodCount];

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

@Override
public Period getPeriod(int periodIndex, Period period, boolean setIds) {
timeline.getPeriod(periodIndex, period, setIds);
long durationUs =
period.durationUs == C.TIME_UNSET
? adPlaybackStates[periodIndex].contentDurationUs
: period.durationUs;
period.set(
period.id,
period.uid,
period.windowIndex,
durationUs,
period.getPositionInWindowUs(),
adPlaybackStates[periodIndex],
period.isPlaceholder);
return period;
}

private AdPlaybackState forPeriod(
AdPlaybackState adPlaybackState,
long periodStartOffsetUs,
long periodDurationUs,
boolean isLastPeriod) {
if (periodStartOffsetUs == 0 && isLastPeriod) {
long contentDurationUs =
periodDurationUs == C.TIME_UNSET ? adPlaybackState.contentDurationUs : periodDurationUs;
return adPlaybackState.withContentDurationUs(contentDurationUs);
}
long contentDurationUs = periodDurationUs;
if (contentDurationUs == C.TIME_UNSET) {
contentDurationUs =
isLastPeriod && adPlaybackState.contentDurationUs != C.TIME_UNSET
? adPlaybackState.contentDurationUs - periodStartOffsetUs
: C.TIME_UNSET;
}
adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs);

for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
long adGroupTimeUs = adPlaybackState.getAdGroup(adGroupIndex).timeUs;
if (adGroupTimeUs == C.TIME_END_OF_SOURCE) {
if (!isLastPeriod) {
adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex);
}
} else {
adPlaybackState =
adPlaybackState.withAdGroupTimeUs(adGroupIndex, adGroupTimeUs - periodStartOffsetUs);
}
}
return adPlaybackState;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ interface EventListener {
* Called when the ad playback state has been updated. The number of {@link
* AdPlaybackState#adGroupCount ad groups} may not change after the first call.
*
* <p>The ad group times in the {@link AdPlaybackState} must be relative to the start of the
* timeline window (i.e. timeUs = 0 corresponds to the start of the window).
*
* @param adPlaybackState The new ad playback state.
*/
default void onAdPlaybackState(AdPlaybackState adPlaybackState) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@
/**
* A {@link MediaSource} that inserts ads linearly into a provided content media source.
*
* <p>The wrapped content media source must contain a single {@link Timeline.Period}.
* <p>The wrapped content media source can contain multiple {@linkplain Timeline.Period periods}.
* Note that multi-period ad insertion is not supported by all {@link AdsLoader} implementations
* (for example, the IMA extension's {@code ImaAdsLoader} only supports single-period Timelines).
*/
@UnstableApi
public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
Expand Down Expand Up @@ -153,11 +155,12 @@ public RuntimeException getRuntimeExceptionForUnexpected() {
private final boolean useLazyContentSourcePreparation;
private final boolean useAdMediaSourceClipping;
private final List<AdMediaSourceHolder> activeMediaSourceHolders;
private final Map<ClippingMediaPeriod, Integer> activeContentClippingMediaPeriods;
private final Map<ClippingMediaPeriod, MediaPeriodId> activeContentClippingMediaPeriods;

// Accessed on the player thread.
@Nullable private ComponentListener componentListener;
@Nullable private Timeline contentTimeline;
@Nullable private Timeline activeTimeline;
@Nullable private AdPlaybackState adPlaybackState;
private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders;
@Nullable private Handler playerHandler;
Expand Down Expand Up @@ -330,14 +333,16 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star
if (id.nextAdGroupIndex == C.INDEX_UNSET) {
return maskingMediaPeriod;
}
long nextAdGroupTimeUs = adPlaybackState.getAdGroup(id.nextAdGroupIndex).timeUs;
long endPositionUs =
getContentClippingEndPositionUs(
checkNotNull(activeTimeline), id.periodUid, id.nextAdGroupIndex);
ClippingMediaPeriod clippingMediaPeriod =
new ClippingMediaPeriod(
maskingMediaPeriod,
/* enableInitialDiscontinuity= */ true,
/* startUs= */ 0,
/* endUs= */ nextAdGroupTimeUs);
activeContentClippingMediaPeriods.put(clippingMediaPeriod, id.nextAdGroupIndex);
/* endUs= */ endPositionUs);
activeContentClippingMediaPeriods.put(clippingMediaPeriod, id);
return clippingMediaPeriod;
}
}
Expand Down Expand Up @@ -375,6 +380,7 @@ protected void releaseSourceInternal() {
this.playerHandler = null;
componentListener.stop();
contentTimeline = null;
activeTimeline = null;
adPlaybackState = null;
adMediaSourceHolders = new AdMediaSourceHolder[0][];
mainHandler.post(() -> adsLoader.stop(/* adsMediaSource= */ this, componentListener));
Expand All @@ -390,7 +396,6 @@ protected void onChildSourceInfoRefreshed(
.handleSourceInfoRefresh(newTimeline);
maybeUpdateSourceInfo();
} else {
checkArgument(newTimeline.getPeriodCount() == 1);
contentTimeline = newTimeline;
mainHandler.post(
() -> {
Expand Down Expand Up @@ -445,14 +450,6 @@ private void onAdPlaybackState(AdPlaybackState adPlaybackState) {
}
}
}
for (Map.Entry<ClippingMediaPeriod, Integer> activeClippingPeriod :
activeContentClippingMediaPeriods.entrySet()) {
int nextAdGroupIndex = activeClippingPeriod.getValue();
long nextAdGroupTimeUs = adPlaybackState.getAdGroup(nextAdGroupIndex).timeUs;
activeClippingPeriod
.getKey()
.updateClipping(/* startUs= */ 0, /* endUs= */ nextAdGroupTimeUs);
}
}
this.adPlaybackState = adPlaybackState;
maybeUpdateAdMediaSources();
Expand Down Expand Up @@ -538,10 +535,20 @@ private void maybeUpdateSourceInfo() {
@Nullable Timeline contentTimeline = this.contentTimeline;
if (adPlaybackState != null && contentTimeline != null) {
if (adPlaybackState.adGroupCount == 0) {
activeTimeline = contentTimeline;
refreshSourceInfo(contentTimeline);
} else {
adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurationsUs());
refreshSourceInfo(new SinglePeriodAdTimeline(contentTimeline, adPlaybackState));
long[][] durations = getAdDurationsUs();
adPlaybackState = adPlaybackState.withAdDurationsUs(durations);
activeTimeline = new AdTimeline(contentTimeline, adPlaybackState);
for (Map.Entry<ClippingMediaPeriod, MediaPeriodId> activeClippingPeriod :
activeContentClippingMediaPeriods.entrySet()) {
MediaPeriodId id = activeClippingPeriod.getValue();
long endPositionUs =
getContentClippingEndPositionUs(activeTimeline, id.periodUid, id.nextAdGroupIndex);
activeClippingPeriod.getKey().updateClipping(/* startUs= */ 0, endPositionUs);
}
refreshSourceInfo(activeTimeline);
}
}
}
Expand Down Expand Up @@ -797,4 +804,14 @@ private MaskingMediaPeriod getActiveMaskingMediaPeriod(int activeMediaPeriodInde
: mediaPeriod);
}
}

private long getContentClippingEndPositionUs(
Timeline activeTimeline, Object periodUid, int nextAdGroupIndex) {
int periodIndex = activeTimeline.getIndexOfPeriod(periodUid);
if (periodIndex == C.INDEX_UNSET) {
return C.TIME_END_OF_SOURCE;
}
activeTimeline.getPeriod(periodIndex, period);
return period.adPlaybackState.getAdGroup(nextAdGroupIndex).timeUs;
}
}

This file was deleted.

Loading