diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 15f3fe7072..ea7dc48d12 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -24,6 +24,9 @@
reason will be removed when a suitable output is connected.
* Fix issue in `PlaybackStatsListener` where spurious `PlaybackStats` are
created after the playlist is cleared.
+ * Add `MediaSource.canUpdateMediaItem` and `MediaSource.updateMediaItem`
+ to accept `MediaItem` updates after creation via
+ `Player.replaceMediaItem(s)`.
* Transformer:
* Parse EXIF rotation data for image inputs.
* Remove `TransformationRequest.HdrMode` annotation type and its
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java
index 9beab13c4f..523d8af15e 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java
@@ -1505,6 +1505,27 @@ public interface ExoPlayer extends Player {
@UnstableApi
void setShuffleOrder(ShuffleOrder shuffleOrder);
+ /**
+ * {@inheritDoc}
+ *
+ *
ExoPlayer will keep the existing {@link MediaSource} for this {@link MediaItem} if
+ * {@linkplain MediaSource#canUpdateMediaItem supported} by the {@link MediaSource}. If the
+ * current item is replaced, this will also not interrupt the ongoing playback.
+ */
+ @Override
+ void replaceMediaItem(int index, MediaItem mediaItem);
+
+ /**
+ * {@inheritDoc}
+ *
+ *
ExoPlayer will keep the existing {@link MediaSource} instances for the new {@link MediaItem
+ * MediaItems} if {@linkplain MediaSource#canUpdateMediaItem supported} by all of these {@link
+ * MediaSource} instances. If the current item is replaced, this will also not interrupt the
+ * ongoing playback.
+ */
+ @Override
+ void replaceMediaItems(int fromIndex, int toIndex, List mediaItems);
+
/**
* Sets the attributes for audio playback, used by the underlying audio track. If not set, the
* default audio attributes will be used. They are suitable for general media playback.
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java
index 80e2212c56..be21578266 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java
@@ -98,9 +98,11 @@ import androidx.media3.exoplayer.analytics.MediaMetricsListener;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
import androidx.media3.exoplayer.metadata.MetadataOutput;
+import androidx.media3.exoplayer.source.MaskingMediaSource;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import androidx.media3.exoplayer.source.ShuffleOrder;
+import androidx.media3.exoplayer.source.TimelineWithUpdatedMediaItem;
import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.text.TextOutput;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
@@ -753,6 +755,11 @@ import java.util.concurrent.TimeoutException;
return;
}
toIndex = min(toIndex, playlistSize);
+ if (canUpdateMediaSourcesWithMediaItems(fromIndex, toIndex, mediaItems)) {
+ // Update MediaSources directly without creating new ones if possible.
+ updateMediaSourcesWithMediaItems(fromIndex, toIndex, mediaItems);
+ return;
+ }
List mediaSources = createMediaSources(mediaItems);
if (mediaSourceHolderSnapshots.isEmpty()) {
// Handle initial items in a playlist as a set operation to ensure state changes and initial
@@ -1938,7 +1945,7 @@ import java.util.concurrent.TimeoutException;
List timelines = ((PlaylistTimeline) newTimeline).getChildTimelines();
checkState(timelines.size() == mediaSourceHolderSnapshots.size());
for (int i = 0; i < timelines.size(); i++) {
- mediaSourceHolderSnapshots.get(i).timeline = timelines.get(i);
+ mediaSourceHolderSnapshots.get(i).updateTimeline(timelines.get(i));
}
}
boolean positionDiscontinuity = false;
@@ -2000,7 +2007,6 @@ import java.util.concurrent.TimeoutException;
repeatCurrentMediaItem);
boolean mediaItemTransitioned = mediaItemTransitionInfo.first;
int mediaItemTransitionReason = mediaItemTransitionInfo.second;
- MediaMetadata newMediaMetadata = mediaMetadata;
@Nullable MediaItem mediaItem = null;
if (mediaItemTransitioned) {
if (!newPlaybackInfo.timeline.isEmpty()) {
@@ -2011,15 +2017,14 @@ import java.util.concurrent.TimeoutException;
}
staticAndDynamicMediaMetadata = MediaMetadata.EMPTY;
}
- if (mediaItemTransitioned
- || !previousPlaybackInfo.staticMetadata.equals(newPlaybackInfo.staticMetadata)) {
+ if (!previousPlaybackInfo.staticMetadata.equals(newPlaybackInfo.staticMetadata)) {
staticAndDynamicMediaMetadata =
staticAndDynamicMediaMetadata
.buildUpon()
.populateFromMetadata(newPlaybackInfo.staticMetadata)
.build();
- newMediaMetadata = buildUpdatedMediaMetadata();
}
+ MediaMetadata newMediaMetadata = buildUpdatedMediaMetadata();
boolean metadataChanged = !newMediaMetadata.equals(mediaMetadata);
mediaMetadata = newMediaMetadata;
boolean playWhenReadyChanged =
@@ -2361,7 +2366,7 @@ import java.util.concurrent.TimeoutException;
new MediaSourceList.MediaSourceHolder(mediaSources.get(i), useLazyPreparation);
holders.add(holder);
mediaSourceHolderSnapshots.add(
- i + index, new MediaSourceHolderSnapshot(holder.uid, holder.mediaSource.getTimeline()));
+ i + index, new MediaSourceHolderSnapshot(holder.uid, holder.mediaSource));
}
shuffleOrder =
shuffleOrder.cloneAndInsert(
@@ -2903,6 +2908,43 @@ import java.util.concurrent.TimeoutException;
}
}
+ private boolean canUpdateMediaSourcesWithMediaItems(
+ int fromIndex, int toIndex, List mediaItems) {
+ if (toIndex - fromIndex != mediaItems.size()) {
+ // Number of items doesn't match.
+ return false;
+ }
+ for (int i = fromIndex; i < toIndex; i++) {
+ MediaSource mediaSource = mediaSourceHolderSnapshots.get(i).mediaSource;
+ if (!mediaSource.canUpdateMediaItem(mediaItems.get(i - fromIndex))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private void updateMediaSourcesWithMediaItems(
+ int fromIndex, int toIndex, List mediaItems) {
+ pendingOperationAcks++;
+ internalPlayer.updateMediaSourcesWithMediaItems(fromIndex, toIndex, mediaItems);
+ for (int i = fromIndex; i < toIndex; i++) {
+ MediaSourceHolderSnapshot snapshot = mediaSourceHolderSnapshots.get(i);
+ snapshot.updateTimeline(
+ new TimelineWithUpdatedMediaItem(snapshot.getTimeline(), mediaItems.get(i - fromIndex)));
+ }
+ Timeline newTimeline = createMaskingTimeline();
+ PlaybackInfo newPlaybackInfo = playbackInfo.copyWithTimeline(newTimeline);
+ updatePlaybackInfo(
+ newPlaybackInfo,
+ /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
+ /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
+ /* ignored */ false,
+ /* ignored */ DISCONTINUITY_REASON_REMOVE,
+ /* ignored */ C.TIME_UNSET,
+ /* ignored */ C.INDEX_UNSET,
+ /* ignored */ false);
+ }
+
private static DeviceInfo createDeviceInfo(@Nullable StreamVolumeManager streamVolumeManager) {
return new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_LOCAL)
.setMinVolume(streamVolumeManager != null ? streamVolumeManager.getMinVolume() : 0)
@@ -2919,12 +2961,14 @@ import java.util.concurrent.TimeoutException;
private static final class MediaSourceHolderSnapshot implements MediaSourceInfoHolder {
private final Object uid;
+ private final MediaSource mediaSource;
private Timeline timeline;
- public MediaSourceHolderSnapshot(Object uid, Timeline timeline) {
+ public MediaSourceHolderSnapshot(Object uid, MaskingMediaSource mediaSource) {
this.uid = uid;
- this.timeline = timeline;
+ this.mediaSource = mediaSource;
+ this.timeline = mediaSource.getTimeline();
}
@Override
@@ -2936,6 +2980,10 @@ import java.util.concurrent.TimeoutException;
public Timeline getTimeline() {
return timeline;
}
+
+ public void updateTimeline(Timeline timeline) {
+ this.timeline = timeline;
+ }
}
private final class ComponentListener
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 ec3c33f5ee..305f91dfca 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java
@@ -33,6 +33,7 @@ import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.IllegalSeekPositionException;
+import androidx.media3.common.MediaItem;
import androidx.media3.common.Metadata;
import androidx.media3.common.ParserException;
import androidx.media3.common.PlaybackException;
@@ -166,6 +167,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
private static final int MSG_SET_OFFLOAD_SCHEDULING_ENABLED = 24;
private static final int MSG_ATTEMPT_RENDERER_ERROR_RECOVERY = 25;
private static final int MSG_RENDERER_CAPABILITIES_CHANGED = 26;
+ private static final int MSG_UPDATE_MEDIA_SOURCES_WITH_MEDIA_ITEMS = 27;
private static final int ACTIVE_INTERVAL_MS = 10;
private static final int IDLE_INTERVAL_MS = 1000;
@@ -407,6 +409,13 @@ import java.util.concurrent.atomic.AtomicBoolean;
handler.obtainMessage(MSG_SET_SHUFFLE_ORDER, shuffleOrder).sendToTarget();
}
+ public void updateMediaSourcesWithMediaItems(
+ int fromIndex, int toIndex, List mediaItems) {
+ handler
+ .obtainMessage(MSG_UPDATE_MEDIA_SOURCES_WITH_MEDIA_ITEMS, fromIndex, toIndex, mediaItems)
+ .sendToTarget();
+ }
+
@Override
public synchronized void sendMessage(PlayerMessage message) {
if (released || !playbackLooper.getThread().isAlive()) {
@@ -500,6 +509,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
// Handler.Callback implementation.
+ @SuppressWarnings("unchecked") // Casting message payload types.
@Override
public boolean handleMessage(Message msg) {
try {
@@ -587,6 +597,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
case MSG_RENDERER_CAPABILITIES_CHANGED:
reselectTracksInternalAndSeek();
break;
+ case MSG_UPDATE_MEDIA_SOURCES_WITH_MEDIA_ITEMS:
+ updateMediaSourcesWithMediaItemsInternal(msg.arg1, msg.arg2, (List) msg.obj);
+ break;
case MSG_RELEASE:
releaseInternal();
// Return immediately to not send playback info updates after release.
@@ -810,6 +823,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
handleMediaSourceListInfoRefreshed(timeline, /* isSourceRefresh= */ false);
}
+ private void updateMediaSourcesWithMediaItemsInternal(
+ int fromIndex, int toIndex, List mediaItems) throws ExoPlaybackException {
+ playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1);
+ Timeline timeline =
+ mediaSourceList.updateMediaSourcesWithMediaItems(fromIndex, toIndex, mediaItems);
+ handleMediaSourceListInfoRefreshed(timeline, /* isSourceRefresh= */ false);
+ }
+
private void notifyTrackSelectionPlayWhenReadyChanged(boolean playWhenReady) {
MediaPeriodHolder periodHolder = queue.getPlayingPeriod();
while (periodHolder != null) {
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java
index a9022d7e0d..64c20835ab 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java
@@ -22,6 +22,7 @@ import static java.lang.Math.min;
import android.os.Handler;
import android.util.Pair;
import androidx.annotation.Nullable;
+import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.HandlerWrapper;
@@ -250,6 +251,27 @@ import java.util.Set;
return createTimeline();
}
+ /**
+ * Updates the specified media sources with new {@link MediaItem media items}.
+ *
+ * @param fromIndex The index of the first media source to update. This index must be in the range
+ * of 0 <= index <= {@link #getSize()}.
+ * @param toIndex The index after the last media source to update. This index must be in the range
+ * of {@code fromIndex} <= index <= {@link #getSize()}.
+ * @param mediaItems The new {@link MediaItem media items} for the specified range of media
+ * sources. Must have a size of {@code toIndex - fromIndex}.
+ * @return The new {@link Timeline}.
+ */
+ public Timeline updateMediaSourcesWithMediaItems(
+ int fromIndex, int toIndex, List mediaItems) {
+ Assertions.checkArgument(fromIndex >= 0 && fromIndex <= toIndex && toIndex <= getSize());
+ Assertions.checkArgument(mediaItems.size() == toIndex - fromIndex);
+ for (int i = fromIndex; i < toIndex; i++) {
+ mediaSourceHolders.get(i).mediaSource.updateMediaItem(mediaItems.get(i - fromIndex));
+ }
+ return createTimeline();
+ }
+
/** Clears the playlist. */
public Timeline clear(@Nullable ShuffleOrder shuffleOrder) {
this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear();
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java
index 3b23dfff60..8383590dfc 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java
@@ -77,6 +77,23 @@ public final class MaskingMediaSource extends WrappingMediaSource {
return timeline;
}
+ @Override
+ public boolean canUpdateMediaItem(MediaItem mediaItem) {
+ return mediaSource.canUpdateMediaItem(mediaItem);
+ }
+
+ @Override
+ public void updateMediaItem(MediaItem mediaItem) {
+ if (hasRealTimeline) {
+ timeline =
+ timeline.cloneWithUpdatedTimeline(
+ new TimelineWithUpdatedMediaItem(timeline.timeline, mediaItem));
+ } else {
+ timeline = MaskingTimeline.createWithPlaceholderTimeline(mediaItem);
+ }
+ mediaSource.updateMediaItem(mediaItem);
+ }
+
@Override
public void prepareSourceInternal() {
if (!useLazyPreparation) {
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java
index d7043eb6da..7343739c02 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java
@@ -280,6 +280,34 @@ public interface MediaSource {
@UnstableApi
MediaItem getMediaItem();
+ /**
+ * Returns whether the {@link MediaItem} for this source can be updated with the provided item.
+ *
+ * Should not be called directly from application code.
+ *
+ *
This method must be called on the application thread.
+ *
+ * @param mediaItem The new {@link MediaItem}.
+ * @return Whether the source can be updated using this item.
+ */
+ @UnstableApi
+ default boolean canUpdateMediaItem(MediaItem mediaItem) {
+ return false;
+ }
+
+ /**
+ * Updates the {@link MediaItem} for this source.
+ *
+ *
Should not be called directly from application code.
+ *
+ *
This method must be called on the playback thread and only if {@link #canUpdateMediaItem}
+ * returns {@code true} for the new {@link MediaItem}.
+ *
+ * @param mediaItem The new {@link MediaItem}.
+ */
+ @UnstableApi
+ default void updateMediaItem(MediaItem mediaItem) {}
+
/**
* @deprecated Implement {@link #prepareSource(MediaSourceCaller, TransferListener, PlayerId)}
* instead.
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java
index b7cc41f04a..3818b82ba8 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java
@@ -167,6 +167,16 @@ public final class MergingMediaSource extends CompositeMediaSource {
return mediaSources.length > 0 ? mediaSources[0].getMediaItem() : PLACEHOLDER_MEDIA_ITEM;
}
+ @Override
+ public boolean canUpdateMediaItem(MediaItem mediaItem) {
+ return mediaSources.length > 0 && mediaSources[0].canUpdateMediaItem(mediaItem);
+ }
+
+ @Override
+ public void updateMediaItem(MediaItem mediaItem) {
+ mediaSources[0].updateMediaItem(mediaItem);
+ }
+
@Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener);
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimelineWithUpdatedMediaItem.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimelineWithUpdatedMediaItem.java
new file mode 100644
index 0000000000..e04f96240c
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimelineWithUpdatedMediaItem.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2023 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;
+
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.Timeline;
+import androidx.media3.common.util.UnstableApi;
+
+/** A {@link Timeline} that overrides the {@link MediaItem}. */
+@UnstableApi
+public final class TimelineWithUpdatedMediaItem extends ForwardingTimeline {
+
+ private final MediaItem updatedMediaItem;
+
+ /**
+ * Creates the timeline.
+ *
+ * @param timeline The wrapped {@link Timeline}.
+ * @param mediaItem The {@link MediaItem} that replaced the original one in {@code timeline}.
+ */
+ public TimelineWithUpdatedMediaItem(Timeline timeline, MediaItem mediaItem) {
+ super(timeline);
+ this.updatedMediaItem = mediaItem;
+ }
+
+ @SuppressWarnings("deprecation") // Setting deprecated field for backward compatibility.
+ @Override
+ public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
+ super.getWindow(windowIndex, window, defaultPositionProjectionUs);
+ window.mediaItem = updatedMediaItem;
+ window.tag =
+ updatedMediaItem.localConfiguration != null
+ ? updatedMediaItem.localConfiguration.tag
+ : null;
+ return window;
+ }
+}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/WrappingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/WrappingMediaSource.java
index 215d90433e..88ebe7a58e 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/WrappingMediaSource.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/WrappingMediaSource.java
@@ -30,7 +30,9 @@ import androidx.media3.exoplayer.upstream.Allocator;
*
*
* - {@link #getMediaItem()}: Amend the {@link MediaItem} for this media source. This is only
- * used before the child source is prepared.
+ * used before the child source is prepared. You may also want to override {@link
+ * #canUpdateMediaItem} or {@link #updateMediaItem} to intercept further updates to the {@link
+ * MediaItem}.
*
- {@link #onChildSourceInfoRefreshed(Timeline)}: Called whenever the child source's {@link
* Timeline} changed. This {@link Timeline} can be amended if needed, for example using {@link
* ForwardingTimeline}. The {@link Timeline} for the wrapping source needs to be published
@@ -88,18 +90,38 @@ public abstract class WrappingMediaSource extends CompositeMediaSource {
}
/**
- * Returns the {@link MediaItem} for this media source.
+ * {@inheritDoc}
*
*
This method can be overridden to amend the {@link MediaItem} of the child source. It is only
* used before the child source is prepared.
- *
- * @see MediaSource#getMediaItem()
*/
@Override
public MediaItem getMediaItem() {
return mediaSource.getMediaItem();
}
+ /**
+ * {@inheritDoc}
+ *
+ *
This method can be overridden to change whether the {@link MediaItem} of the child source
+ * can be updated.
+ */
+ @Override
+ public boolean canUpdateMediaItem(MediaItem mediaItem) {
+ return mediaSource.canUpdateMediaItem(mediaItem);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
This method can be overridden to change how the {@link MediaItem} of the child source is
+ * updated.
+ */
+ @Override
+ public void updateMediaItem(MediaItem mediaItem) {
+ mediaSource.updateMediaItem(mediaItem);
+ }
+
/**
* Creates the requested {@link MediaPeriod}.
*
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 5015b9f67f..0df59f8620 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
@@ -191,6 +191,16 @@ public final class AdsMediaSource extends CompositeMediaSource {
return contentMediaSource.getMediaItem();
}
+ @Override
+ public boolean canUpdateMediaItem(MediaItem mediaItem) {
+ return contentMediaSource.canUpdateMediaItem(mediaItem);
+ }
+
+ @Override
+ public void updateMediaItem(MediaItem mediaItem) {
+ contentMediaSource.updateMediaItem(mediaItem);
+ }
+
@Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener);
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java
index 9d21ae8871..81203c5cba 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/ServerSideAdInsertionMediaSource.java
@@ -219,6 +219,16 @@ public final class ServerSideAdInsertionMediaSource extends BaseMediaSource
return mediaSource.getMediaItem();
}
+ @Override
+ public boolean canUpdateMediaItem(MediaItem mediaItem) {
+ return mediaSource.canUpdateMediaItem(mediaItem);
+ }
+
+ @Override
+ public void updateMediaItem(MediaItem mediaItem) {
+ mediaSource.updateMediaItem(mediaItem);
+ }
+
@Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
Handler handler = Util.createHandlerForCurrentLooper();
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java
index 9b93b8512d..3b11df3ca0 100644
--- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java
@@ -87,6 +87,7 @@ import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf;
import android.content.Context;
@@ -13140,6 +13141,202 @@ public final class ExoPlayerTest {
player.release();
}
+ @Test
+ public void replaceMediaItems_withOnlyPartiallyReplaceableItemsInMediaSource_createsNewItems() {
+ FakeMediaSource sourceWithoutReplaceableItem = new FakeMediaSource();
+ sourceWithoutReplaceableItem.setCanUpdateMediaItems(false);
+ FakeMediaSource sourceWithReplaceableItem = new FakeMediaSource();
+ sourceWithReplaceableItem.setCanUpdateMediaItems(true);
+ MediaSource.Factory mockFactory = mock(MediaSource.Factory.class);
+ when(mockFactory.createMediaSource(any())).thenReturn(new FakeMediaSource());
+ ExoPlayer player = new TestExoPlayerBuilder(context).setMediaSourceFactory(mockFactory).build();
+ player.addMediaSources(
+ ImmutableList.of(sourceWithoutReplaceableItem, sourceWithReplaceableItem));
+ MediaItem mediaItem0 = new MediaItem.Builder().setMediaId("0").build();
+ MediaItem mediaItem1 = new MediaItem.Builder().setMediaId("1").build();
+
+ player.replaceMediaItems(
+ /* fromIndex= */ 0, /* toIndex= */ 2, ImmutableList.of(mediaItem0, mediaItem1));
+
+ verify(mockFactory).createMediaSource(mediaItem0);
+ verify(mockFactory).createMediaSource(mediaItem1);
+ player.release();
+ }
+
+ @Test
+ public void
+ replaceMediaItems_withReplaceableItemsInMediaSourceBeforePreparation_updatesMediaItems()
+ throws Exception {
+ FakeMediaSource unaffectedSource = new FakeMediaSource();
+ FakeMediaSource source0 = new FakeMediaSource();
+ source0.setCanUpdateMediaItems(true);
+ FakeMediaSource source1 = new FakeMediaSource();
+ source1.setCanUpdateMediaItems(true);
+ ExoPlayer player = new TestExoPlayerBuilder(context).build();
+ player.addMediaSources(ImmutableList.of(source0, source1, unaffectedSource));
+ MediaItem unaffectedMediaItem = unaffectedSource.getMediaItem();
+ MediaItem mediaItem0 =
+ new MediaItem.Builder()
+ .setMediaId("0")
+ .setMediaMetadata(new MediaMetadata.Builder().setTitle("0").build())
+ .build();
+ MediaItem mediaItem1 =
+ new MediaItem.Builder()
+ .setMediaId("1")
+ .setMediaMetadata(new MediaMetadata.Builder().setTitle("1").build())
+ .build();
+ Player.Listener listener = mock(Player.Listener.class);
+ player.addListener(listener);
+
+ player.replaceMediaItems(
+ /* fromIndex= */ 0, /* toIndex= */ 2, ImmutableList.of(mediaItem0, mediaItem1));
+
+ assertThat(player.getMediaItemAt(0)).isEqualTo(mediaItem0);
+ assertThat(player.getMediaItemAt(1)).isEqualTo(mediaItem1);
+ assertThat(player.getMediaItemAt(2)).isEqualTo(unaffectedMediaItem);
+ verify(listener)
+ .onTimelineChanged(
+ player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
+ verify(listener).onMediaMetadataChanged(new MediaMetadata.Builder().setTitle("0").build());
+ verifyNoMoreInteractions(listener);
+
+ // Verify that preparing the source keeps the updated item.
+ reset(listener);
+ player.prepare();
+ runUntilPlaybackState(player, Player.STATE_READY);
+
+ assertThat(player.getMediaItemAt(0)).isEqualTo(mediaItem0);
+ assertThat(player.getMediaItemAt(1)).isEqualTo(mediaItem1);
+ assertThat(player.getMediaItemAt(2)).isEqualTo(unaffectedMediaItem);
+ verify(listener)
+ .onTimelineChanged(
+ player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE);
+ verify(listener, never()).onMediaMetadataChanged(any());
+ verify(listener, never()).onMediaItemTransition(any(), anyInt());
+ player.release();
+ }
+
+ @Test
+ public void
+ replaceMediaItems_withReplaceableItemsInMediaSourceImmediatelyAfterPreparation_updatesMediaItems()
+ throws Exception {
+ FakeMediaSource unaffectedSource = new FakeMediaSource();
+ FakeMediaSource source0 = new FakeMediaSource();
+ source0.setCanUpdateMediaItems(true);
+ FakeMediaSource source1 = new FakeMediaSource();
+ source1.setCanUpdateMediaItems(true);
+ ExoPlayer player = new TestExoPlayerBuilder(context).build();
+ player.addMediaSources(ImmutableList.of(source0, source1, unaffectedSource));
+ MediaItem unaffectedMediaItem = unaffectedSource.getMediaItem();
+ MediaItem mediaItem0 =
+ new MediaItem.Builder()
+ .setMediaId("0")
+ .setMediaMetadata(new MediaMetadata.Builder().setTitle("0").build())
+ .build();
+ MediaItem mediaItem1 =
+ new MediaItem.Builder()
+ .setMediaId("1")
+ .setMediaMetadata(new MediaMetadata.Builder().setTitle("1").build())
+ .build();
+ Player.Listener listener = mock(Player.Listener.class);
+
+ player.prepare();
+ player.addListener(listener);
+ player.replaceMediaItems(
+ /* fromIndex= */ 0, /* toIndex= */ 2, ImmutableList.of(mediaItem0, mediaItem1));
+
+ // Immediate updates from the prepare and replace operations.
+ assertThat(player.getMediaItemAt(0)).isEqualTo(mediaItem0);
+ assertThat(player.getMediaItemAt(1)).isEqualTo(mediaItem1);
+ assertThat(player.getMediaItemAt(2)).isEqualTo(unaffectedMediaItem);
+ verify(listener)
+ .onTimelineChanged(
+ player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
+ verify(listener).onMediaMetadataChanged(new MediaMetadata.Builder().setTitle("0").build());
+ verifyNoMoreInteractions(listener);
+
+ // Verify that the preparation finished without another MediaItem or metadata update.
+ reset(listener);
+ runUntilPendingCommandsAreFullyHandled(player);
+
+ assertThat(player.getMediaItemAt(0)).isEqualTo(mediaItem0);
+ assertThat(player.getMediaItemAt(1)).isEqualTo(mediaItem1);
+ assertThat(player.getMediaItemAt(2)).isEqualTo(unaffectedMediaItem);
+ verify(listener)
+ .onTimelineChanged(
+ player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE);
+ verify(listener, never()).onMediaMetadataChanged(any());
+ verify(listener, never()).onMediaItemTransition(any(), anyInt());
+ player.release();
+ }
+
+ @Test
+ public void
+ replaceMediaItems_withReplaceableItemsInMediaSourceDuringPlayback_updatesMediaItemsWithoutInterruption()
+ throws Exception {
+ FakeMediaSource unaffectedSource = new FakeMediaSource();
+ FakeMediaSource source0 = new FakeMediaSource();
+ source0.setCanUpdateMediaItems(true);
+ FakeMediaSource source1 = new FakeMediaSource();
+ source1.setCanUpdateMediaItems(true);
+ ExoPlayer player = new TestExoPlayerBuilder(context).build();
+ player.addMediaSources(ImmutableList.of(source0, source1, unaffectedSource));
+ MediaItem unaffectedMediaItem = unaffectedSource.getMediaItem();
+ MediaItem mediaItem0 =
+ new MediaItem.Builder()
+ .setMediaId("0")
+ .setMediaMetadata(new MediaMetadata.Builder().setTitle("0").build())
+ .build();
+ MediaItem mediaItem1 =
+ new MediaItem.Builder()
+ .setMediaId("1")
+ .setMediaMetadata(new MediaMetadata.Builder().setTitle("1").build())
+ .build();
+ player.prepare();
+ runUntilPlaybackState(player, Player.STATE_READY);
+ player.play();
+ Player.Listener listener = mock(Player.Listener.class);
+ player.addListener(listener);
+
+ player.replaceMediaItems(
+ /* fromIndex= */ 0, /* toIndex= */ 2, ImmutableList.of(mediaItem0, mediaItem1));
+
+ // Immediate updates from the replace operation.
+ assertThat(player.getMediaItemAt(0)).isEqualTo(mediaItem0);
+ assertThat(player.getMediaItemAt(1)).isEqualTo(mediaItem1);
+ assertThat(player.getMediaItemAt(2)).isEqualTo(unaffectedMediaItem);
+ verify(listener)
+ .onTimelineChanged(
+ player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
+ verify(listener).onMediaMetadataChanged(new MediaMetadata.Builder().setTitle("0").build());
+ verifyNoMoreInteractions(listener);
+
+ // Verify that the update causes no immediate interruption.
+ reset(listener);
+ runUntilPendingCommandsAreFullyHandled(player);
+ assertThat(player.getMediaItemAt(0)).isEqualTo(mediaItem0);
+ assertThat(player.getMediaItemAt(1)).isEqualTo(mediaItem1);
+ assertThat(player.getMediaItemAt(2)).isEqualTo(unaffectedMediaItem);
+ verify(listener, never()).onTimelineChanged(any(), anyInt());
+ verify(listener, never()).onMediaMetadataChanged(any());
+ verify(listener, never()).onMediaItemTransition(any(), anyInt());
+ verify(listener, never()).onPlaybackStateChanged(Player.STATE_BUFFERING);
+
+ // Verify that playback finishes with the expected item and metadata transitions.
+ reset(listener);
+ runUntilPlaybackState(player, Player.STATE_ENDED);
+
+ verify(listener, never()).onTimelineChanged(any(), anyInt());
+ verify(listener, times(2)).onMediaMetadataChanged(any());
+ verify(listener).onMediaMetadataChanged(new MediaMetadata.Builder().setTitle("1").build());
+ verify(listener).onMediaMetadataChanged(MediaMetadata.EMPTY);
+ verify(listener, times(2)).onMediaItemTransition(any(), anyInt());
+ verify(listener).onMediaItemTransition(mediaItem1, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO);
+ verify(listener)
+ .onMediaItemTransition(unaffectedMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO);
+ player.release();
+ }
+
/**
* Tests playback suppression for playback with only unsuitable outputs (e.g. builtin speaker) on
* the Wear OS.
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java
index 8156a5c85b..eeed3b4d72 100644
--- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaSourceListTest.java
@@ -40,6 +40,7 @@ import androidx.media3.test.utils.FakeMediaSource;
import androidx.media3.test.utils.FakeShuffleOrder;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -508,6 +509,40 @@ public class MediaSourceListTest {
mediaSourceList.setShuffleOrder(new FakeShuffleOrder(MEDIA_SOURCE_LIST_SIZE)));
}
+ @Test
+ public void updateMediaSourcesWithMediaItems_updatesMediaItemsForPreparedAndPlaceholderSources() {
+ FakeMediaSource unaffectedSource = new FakeMediaSource();
+ FakeMediaSource preparedSource = new FakeMediaSource();
+ preparedSource.setCanUpdateMediaItems(true);
+ preparedSource.setAllowPreparation(true);
+ FakeMediaSource unpreparedSource = new FakeMediaSource();
+ unpreparedSource.setCanUpdateMediaItems(true);
+ unpreparedSource.setAllowPreparation(false);
+ mediaSourceList.setMediaSources(
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false, unaffectedSource, preparedSource, unpreparedSource),
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 3));
+ mediaSourceList.prepare(/* mediaTransferListener= */ null);
+ MediaItem unaffectedMediaItem = unaffectedSource.getMediaItem();
+ MediaItem updatedItem1 = new MediaItem.Builder().setMediaId("1").build();
+ MediaItem updatedItem2 = new MediaItem.Builder().setMediaId("2").build();
+
+ Timeline timeline =
+ mediaSourceList.updateMediaSourcesWithMediaItems(
+ /* fromIndex= */ 1, /* toIndex= */ 3, ImmutableList.of(updatedItem1, updatedItem2));
+
+ assertThat(timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).mediaItem)
+ .isEqualTo(unaffectedMediaItem);
+ assertThat(timeline.getWindow(/* windowIndex= */ 1, new Timeline.Window()).mediaItem)
+ .isEqualTo(updatedItem1);
+ assertThat(timeline.getWindow(/* windowIndex= */ 1, new Timeline.Window()).isPlaceholder)
+ .isFalse();
+ assertThat(timeline.getWindow(/* windowIndex= */ 2, new Timeline.Window()).mediaItem)
+ .isEqualTo(updatedItem2);
+ assertThat(timeline.getWindow(/* windowIndex= */ 2, new Timeline.Window()).isPlaceholder)
+ .isTrue();
+ }
+
// Internal methods.
private static void assertTimelineUsesFakeShuffleOrder(Timeline timeline) {
diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSource.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSource.java
index 1ecc9fec67..bf8c963413 100644
--- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSource.java
+++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeMediaSource.java
@@ -44,6 +44,7 @@ import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.MediaSourceEventListener;
+import androidx.media3.exoplayer.source.TimelineWithUpdatedMediaItem;
import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.test.utils.FakeMediaPeriod.TrackDataFactory;
@@ -98,6 +99,7 @@ public class FakeMediaSource extends BaseMediaSource {
private final ArrayList createdMediaPeriods;
private final DrmSessionManager drmSessionManager;
+ private boolean canUpdateMediaItems;
private boolean preparationAllowed;
private @MonotonicNonNull Timeline timeline;
private boolean preparedSource;
@@ -168,6 +170,7 @@ public class FakeMediaSource extends BaseMediaSource {
this.drmSessionManager = drmSessionManager;
this.trackDataFactory = trackDataFactory;
preparationAllowed = true;
+ canUpdateMediaItems = false;
}
/**
@@ -185,6 +188,15 @@ public class FakeMediaSource extends BaseMediaSource {
}
}
+ /**
+ * Sets whether the source allows to update its {@link MediaItem} via {@link #updateMediaItem}.
+ *
+ * @param canUpdateMediaItems Whether a {@link MediaItem} update is possible.
+ */
+ public void setCanUpdateMediaItems(boolean canUpdateMediaItems) {
+ this.canUpdateMediaItems = canUpdateMediaItems;
+ }
+
@Nullable
protected Timeline getTimeline() {
return timeline;
@@ -198,6 +210,22 @@ public class FakeMediaSource extends BaseMediaSource {
return timeline.getWindow(0, new Timeline.Window()).mediaItem;
}
+ @Override
+ public boolean canUpdateMediaItem(MediaItem mediaItem) {
+ return canUpdateMediaItems;
+ }
+
+ @Override
+ public void updateMediaItem(MediaItem mediaItem) {
+ if (timeline == null) {
+ return;
+ }
+ timeline = new TimelineWithUpdatedMediaItem(timeline, mediaItem);
+ if (preparedSource && preparationAllowed) {
+ refreshSourceInfo(timeline);
+ }
+ }
+
@Override
@Nullable
public Timeline getInitialTimeline() {