diff --git a/libraries/common/src/main/java/androidx/media3/common/Timeline.java b/libraries/common/src/main/java/androidx/media3/common/Timeline.java
index 463ff0aca3..fb93681f05 100644
--- a/libraries/common/src/main/java/androidx/media3/common/Timeline.java
+++ b/libraries/common/src/main/java/androidx/media3/common/Timeline.java
@@ -26,6 +26,7 @@ import android.os.IBinder;
import android.os.SystemClock;
import android.util.Pair;
import androidx.annotation.Nullable;
+import androidx.media3.common.MediaItem.LocalConfiguration;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.BundleCollectionUtil;
import androidx.media3.common.util.UnstableApi;
@@ -175,14 +176,22 @@ public abstract class Timeline {
public Object uid;
/**
- * @deprecated Use {@link #mediaItem} instead.
+ * @deprecated Use {@link LocalConfiguration#tag} of {@link #mediaItem} instead.
*/
@UnstableApi @Deprecated @Nullable public Object tag;
/** The {@link MediaItem} associated to the window. Not necessarily unique. */
public MediaItem mediaItem;
- /** The manifest of the window. May be {@code null}. */
+ /**
+ * The manifest of the window. May be {@code null}.
+ *
+ *
The concrete type depends on the media sources producing the timeline window. Examples
+ * provided by Media3 media source modules are {@code
+ * androidx.media3.exoplayer.dash.manifest.DashManifest}, {@code
+ * androidx.media3.exoplayer.hls.HlsManifest} and {@code
+ * androidx.media3.exoplayer.smoothstreaming.SSManifest}.
+ */
@Nullable public Object manifest;
/**
@@ -263,7 +272,7 @@ public abstract class Timeline {
/** Sets the data held by this window. */
@CanIgnoreReturnValue
@UnstableApi
- @SuppressWarnings("deprecation")
+ @SuppressWarnings("deprecation") // Using Window.tag for backwards compatibility
public Window set(
Object uid,
@Nullable MediaItem mediaItem,
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java
index 73281d2218..46277d1896 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsLoader.java
@@ -134,6 +134,12 @@ public interface AdsLoader {
/**
* Starts using the ads loader for playback. Called on the main thread by {@link AdsMediaSource}.
*
+ *
Requests the ads loader to start loading ad data from the provided {@link DataSpec
+ * adTagDataSpec}. Publishing an initial {@link AdPlaybackState} to provided {@link
+ * EventListener#onAdPlaybackState(AdPlaybackState) eventListener} is required to start playback.
+ * In the case of a pre roll, this ensures that the player doesn't briefly start playing content
+ * before ad data is available.
+ *
* @param adsMediaSource The ads media source requesting to start loading ads.
* @param adTagDataSpec A data spec for the ad tag to load.
* @param adsId An opaque identifier for the ad playback state across start/stop calls.
@@ -162,18 +168,39 @@ public interface AdsLoader {
* Notifies the ads loader when the content source has changed its timeline. Called on the main
* thread by {@link AdsMediaSource}.
*
- *
If you override this callback for the purpose of reading ad data from the timeline to
- * populate the {@link AdPlaybackState} with, you need to pass true to the constructor of {@link
+ *
The default implementation returns false which makes the content timeline immediately being
+ * reported to the player.
+ *
+ *
When overriding this method for the purpose of reading ad data from the timeline to populate
+ * the {@link AdPlaybackState} with, false needs to be passed to the constructor of {@link
* AdsMediaSource#AdsMediaSource(MediaSource, DataSpec, Object, MediaSource.Factory, AdsLoader,
- * AdViewProvider, boolean) AdsMediaSource} to indicate the content source needs to be prepared
- * upfront.
+ * AdViewProvider, boolean) AdsMediaSource} to indicate that the content source needs to be
+ * prepared upfront. This way an ads loader can defer calling {@link
+ * EventListener#onAdPlaybackState(AdPlaybackState)} until the ad data from the timeline is
+ * available and populate the initial ad playback state with that data before publishing.
+ *
+ *
For live streams, this method is called additional times when the content source reports an
+ * advancing {@linkplain Timeline live window} with new available media and/or new ad data in the
+ * manifest. If in such a case, the ads loader as a result calls {@link
+ * EventListener#onAdPlaybackState(AdPlaybackState)}, true must be returned. This prevents the
+ * timeline being reported with stale ad data. Conversely, when the ad playback state is not
+ * passed into {@link EventListener#onAdPlaybackState(AdPlaybackState)}, false must be returned to
+ * not drop a timeline update that needs to be published to the player.
+ *
+ *
Generally, if the timeline is not required to populate the ad playback state, {@link
+ * #start(AdsMediaSource, DataSpec, Object, AdViewProvider, EventListener)} should be used to
+ * initiate loading ad data and publish the first ad playback state as early as possible. This
+ * method can still be overridden for informational or other purpose. In this case, false is
+ * returned here and the {@link AdsMediaSource} is used with lazy preparation enabled.
*
* @param adsMediaSource The ads media source for which the content timeline changed.
* @param timeline The timeline of the content source.
+ * @return true If {@link EventListener#onAdPlaybackState(AdPlaybackState)} is or will be called,
+ * false otherwise.
*/
@UnstableApi
- default void handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) {
- // Do nothing.
+ default boolean handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) {
+ return false;
}
/**
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 a616278181..64e77da0f7 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
@@ -147,12 +147,14 @@ public final class AdsMediaSource extends CompositeMediaSource {
private final Object adsId;
private final Handler mainHandler;
private final Timeline.Period period;
+ private final boolean useLazyContentSourcePreparation;
// Accessed on the player thread.
@Nullable private ComponentListener componentListener;
@Nullable private Timeline contentTimeline;
@Nullable private AdPlaybackState adPlaybackState;
private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders;
+ @Nullable private Handler playerHandler;
/**
* Constructs a new source that inserts ads linearly with the content specified by {@code
@@ -205,8 +207,8 @@ public final class AdsMediaSource extends CompositeMediaSource {
* @param useLazyContentSourcePreparation True if the content source should be prepared lazily and
* wait for an {@link AdPlaybackState} to be set before preparing. False if the timeline is
* required {@linkplain AdsLoader#handleContentTimelineChanged(AdsMediaSource, Timeline) to
- * read ad data from it} to populate the {@link AdPlaybackState} (for instance from HLS
- * interstitials).
+ * read ad data from it} to populate the {@link AdPlaybackState} (See {@link
+ * Timeline.Window#manifest} also).
*/
public AdsMediaSource(
MediaSource contentMediaSource,
@@ -216,6 +218,7 @@ public final class AdsMediaSource extends CompositeMediaSource {
AdsLoader adsLoader,
AdViewProvider adViewProvider,
boolean useLazyContentSourcePreparation) {
+ this.useLazyContentSourcePreparation = useLazyContentSourcePreparation;
this.contentMediaSource =
new MaskingMediaSource(
contentMediaSource, /* useLazyPreparation= */ useLazyContentSourcePreparation);
@@ -256,7 +259,8 @@ public final class AdsMediaSource extends CompositeMediaSource {
@Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener);
- ComponentListener componentListener = new ComponentListener();
+ this.playerHandler = Util.createHandlerForCurrentLooper();
+ ComponentListener componentListener = new ComponentListener(playerHandler);
this.componentListener = componentListener;
contentTimeline = contentMediaSource.getTimeline();
prepareChildSource(CHILD_SOURCE_MEDIA_PERIOD_ID, contentMediaSource);
@@ -320,6 +324,7 @@ public final class AdsMediaSource extends CompositeMediaSource {
super.releaseSourceInternal();
ComponentListener componentListener = checkNotNull(this.componentListener);
this.componentListener = null;
+ this.playerHandler = null;
componentListener.stop();
contentTimeline = null;
adPlaybackState = null;
@@ -335,12 +340,26 @@ public final class AdsMediaSource extends CompositeMediaSource {
int adIndexInAdGroup = childSourceId.adIndexInAdGroup;
checkNotNull(adMediaSourceHolders[adGroupIndex][adIndexInAdGroup])
.handleSourceInfoRefresh(newTimeline);
+ maybeUpdateSourceInfo();
} else {
Assertions.checkArgument(newTimeline.getPeriodCount() == 1);
contentTimeline = newTimeline;
- mainHandler.post(() -> adsLoader.handleContentTimelineChanged(this, newTimeline));
+ mainHandler.post(
+ () -> {
+ boolean sourceInfoUpdated = adsLoader.handleContentTimelineChanged(this, newTimeline);
+ // The ad playback state must not be updated when lazy preparation is used.
+ checkState(!sourceInfoUpdated || !useLazyContentSourcePreparation);
+ // If the source isn't updated by the ads loader we do, if not already published.
+ if (!sourceInfoUpdated && !useLazyContentSourcePreparation) {
+ checkNotNull(playerHandler).post(this::maybeUpdateSourceInfo);
+ }
+ });
+ if (useLazyContentSourcePreparation) {
+ // If lazy preparation is used, the ads loader is not allowed to update the ad playback
+ // state on timeline change. We can synchronously publish the timeline as early as possible.
+ maybeUpdateSourceInfo();
+ }
}
- maybeUpdateSourceInfo();
}
@Override
@@ -495,8 +514,8 @@ public final class AdsMediaSource extends CompositeMediaSource {
* Creates new listener which forwards ad playback states on the creating thread and all other
* events on the external event listener thread.
*/
- public ComponentListener() {
- playerHandler = Util.createHandlerForCurrentLooper();
+ public ComponentListener(Handler playerHandler) {
+ this.playerHandler = playerHandler;
}
/** Stops event delivery from this instance. */
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java
index ebaffda29c..4d1adf768c 100644
--- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java
@@ -242,6 +242,7 @@ public final class AdsMediaSourceTest {
mock(Allocator.class),
/* startPositionUs= */ 0);
+ shadowOf(Looper.getMainLooper()).idle();
contentMediaSource.assertMediaPeriodCreated(
new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0));
ArgumentCaptor adsTimelineCaptor = ArgumentCaptor.forClass(Timeline.class);
@@ -396,9 +397,10 @@ public final class AdsMediaSourceTest {
}
@Override
- public void handleContentTimelineChanged(
+ public boolean handleContentTimelineChanged(
AdsMediaSource adsMediaSource, Timeline timeline) {
contentTimelineChangedCalledLatch.countDown();
+ return false;
}
};
MediaSource.Factory adMediaSourceFactory = mock(MediaSource.Factory.class);
@@ -573,9 +575,10 @@ public final class AdsMediaSourceTest {
}
@Override
- public void handleContentTimelineChanged(
+ public boolean handleContentTimelineChanged(
AdsMediaSource adsMediaSource, Timeline timeline) {
contentTimelineChangedCallCount.incrementAndGet();
+ return false;
}
};
MediaSource.Factory adMediaSourceFactory = mock(MediaSource.Factory.class);
@@ -966,9 +969,6 @@ public final class AdsMediaSourceTest {
int adGroupIndex,
int adIndexInAdGroup,
IOException exception) {}
-
- @Override
- public void handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) {}
}
private static MediaSource buildMediaSource(MediaItem mediaItem) {
diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java
index 6baf5559e4..9885141c48 100644
--- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java
+++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java
@@ -591,7 +591,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
}
@Override
- public void handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) {
+ public boolean handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timeline timeline) {
Object adsId = adsMediaSource.getAdsId();
if (isReleased) {
EventListener eventListener = activeEventListeners.remove(adsId);
@@ -604,14 +604,14 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
eventListener.onAdPlaybackState(new AdPlaybackState(adsId));
}
}
- return;
+ return false;
}
AdPlaybackState adPlaybackState = checkNotNull(activeAdPlaybackStates.get(adsId));
if (!adPlaybackState.equals(AdPlaybackState.NONE)
&& !adPlaybackState.endsWithLivePostrollPlaceHolder()) {
// Multiple timeline updates for VOD not supported.
- return;
+ return false;
}
if (adPlaybackState.equals(AdPlaybackState.NONE)) {
@@ -662,12 +662,13 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
adsId, timeline, /* windowIndex= */ 0, contentPositionUs);
}
}
- putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState);
+ boolean adPlaybackStateUpdated = putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState);
if (!unsupportedAdsIds.contains(adsId)) {
notifyListeners(
listener ->
listener.onContentTimelineChanged(adsMediaSource.getMediaItem(), adsId, timeline));
}
+ return adPlaybackStateUpdated;
}
@Override
@@ -864,18 +865,20 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
return loader;
}
- private void putAndNotifyAdPlaybackStateUpdate(Object adsId, AdPlaybackState adPlaybackState) {
+ private boolean putAndNotifyAdPlaybackStateUpdate(Object adsId, AdPlaybackState adPlaybackState) {
@Nullable
AdPlaybackState oldAdPlaybackState = activeAdPlaybackStates.put(adsId, adPlaybackState);
if (!adPlaybackState.equals(oldAdPlaybackState)) {
@Nullable EventListener eventListener = activeEventListeners.get(adsId);
if (eventListener != null) {
eventListener.onAdPlaybackState(adPlaybackState);
+ return true;
} else {
activeAdPlaybackStates.remove(adsId);
insertedInterstitialIds.remove(adsId);
}
}
+ return false;
}
private void notifyAssetResolutionFailed(Object adsId, int adGroupIndex, int adIndexInAdGroup) {