Add MediaSource.canUpdateMediaItem/updateMediaItem

This allows MediaSources to accept MediaItem updates after creation.

This CL adds the handling and plumbing logic in `ExoPlayerImpl`,
`ExoPlayerImplInternal`, `MediaSourceList` and `MaskingMediaSource`.

It also updates all forwarding/wrapping sources to forward these calls
to their wrapped instance.

The actual functionality is only added to `FakeMediaSource` instances in
tests so far.

PiperOrigin-RevId: 545450210
This commit is contained in:
tonihei 2023-07-04 15:08:35 +00:00 committed by microkatz
parent c33a17d89c
commit aa57d48347
15 changed files with 534 additions and 12 deletions

View file

@ -24,6 +24,9 @@
reason will be removed when a suitable output is connected. reason will be removed when a suitable output is connected.
* Fix issue in `PlaybackStatsListener` where spurious `PlaybackStats` are * Fix issue in `PlaybackStatsListener` where spurious `PlaybackStats` are
created after the playlist is cleared. created after the playlist is cleared.
* Add `MediaSource.canUpdateMediaItem` and `MediaSource.updateMediaItem`
to accept `MediaItem` updates after creation via
`Player.replaceMediaItem(s)`.
* Transformer: * Transformer:
* Parse EXIF rotation data for image inputs. * Parse EXIF rotation data for image inputs.
* Remove `TransformationRequest.HdrMode` annotation type and its * Remove `TransformationRequest.HdrMode` annotation type and its

View file

@ -1505,6 +1505,27 @@ public interface ExoPlayer extends Player {
@UnstableApi @UnstableApi
void setShuffleOrder(ShuffleOrder shuffleOrder); void setShuffleOrder(ShuffleOrder shuffleOrder);
/**
* {@inheritDoc}
*
* <p>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}
*
* <p>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<MediaItem> mediaItems);
/** /**
* Sets the attributes for audio playback, used by the underlying audio track. If not set, the * 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. * default audio attributes will be used. They are suitable for general media playback.

View file

@ -98,9 +98,11 @@ import androidx.media3.exoplayer.analytics.MediaMetricsListener;
import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.audio.AudioRendererEventListener; import androidx.media3.exoplayer.audio.AudioRendererEventListener;
import androidx.media3.exoplayer.metadata.MetadataOutput; import androidx.media3.exoplayer.metadata.MetadataOutput;
import androidx.media3.exoplayer.source.MaskingMediaSource;
import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import androidx.media3.exoplayer.source.ShuffleOrder; import androidx.media3.exoplayer.source.ShuffleOrder;
import androidx.media3.exoplayer.source.TimelineWithUpdatedMediaItem;
import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.text.TextOutput; import androidx.media3.exoplayer.text.TextOutput;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
@ -753,6 +755,11 @@ import java.util.concurrent.TimeoutException;
return; return;
} }
toIndex = min(toIndex, playlistSize); toIndex = min(toIndex, playlistSize);
if (canUpdateMediaSourcesWithMediaItems(fromIndex, toIndex, mediaItems)) {
// Update MediaSources directly without creating new ones if possible.
updateMediaSourcesWithMediaItems(fromIndex, toIndex, mediaItems);
return;
}
List<MediaSource> mediaSources = createMediaSources(mediaItems); List<MediaSource> mediaSources = createMediaSources(mediaItems);
if (mediaSourceHolderSnapshots.isEmpty()) { if (mediaSourceHolderSnapshots.isEmpty()) {
// Handle initial items in a playlist as a set operation to ensure state changes and initial // 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<Timeline> timelines = ((PlaylistTimeline) newTimeline).getChildTimelines(); List<Timeline> timelines = ((PlaylistTimeline) newTimeline).getChildTimelines();
checkState(timelines.size() == mediaSourceHolderSnapshots.size()); checkState(timelines.size() == mediaSourceHolderSnapshots.size());
for (int i = 0; i < timelines.size(); i++) { 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; boolean positionDiscontinuity = false;
@ -2000,7 +2007,6 @@ import java.util.concurrent.TimeoutException;
repeatCurrentMediaItem); repeatCurrentMediaItem);
boolean mediaItemTransitioned = mediaItemTransitionInfo.first; boolean mediaItemTransitioned = mediaItemTransitionInfo.first;
int mediaItemTransitionReason = mediaItemTransitionInfo.second; int mediaItemTransitionReason = mediaItemTransitionInfo.second;
MediaMetadata newMediaMetadata = mediaMetadata;
@Nullable MediaItem mediaItem = null; @Nullable MediaItem mediaItem = null;
if (mediaItemTransitioned) { if (mediaItemTransitioned) {
if (!newPlaybackInfo.timeline.isEmpty()) { if (!newPlaybackInfo.timeline.isEmpty()) {
@ -2011,15 +2017,14 @@ import java.util.concurrent.TimeoutException;
} }
staticAndDynamicMediaMetadata = MediaMetadata.EMPTY; staticAndDynamicMediaMetadata = MediaMetadata.EMPTY;
} }
if (mediaItemTransitioned if (!previousPlaybackInfo.staticMetadata.equals(newPlaybackInfo.staticMetadata)) {
|| !previousPlaybackInfo.staticMetadata.equals(newPlaybackInfo.staticMetadata)) {
staticAndDynamicMediaMetadata = staticAndDynamicMediaMetadata =
staticAndDynamicMediaMetadata staticAndDynamicMediaMetadata
.buildUpon() .buildUpon()
.populateFromMetadata(newPlaybackInfo.staticMetadata) .populateFromMetadata(newPlaybackInfo.staticMetadata)
.build(); .build();
newMediaMetadata = buildUpdatedMediaMetadata();
} }
MediaMetadata newMediaMetadata = buildUpdatedMediaMetadata();
boolean metadataChanged = !newMediaMetadata.equals(mediaMetadata); boolean metadataChanged = !newMediaMetadata.equals(mediaMetadata);
mediaMetadata = newMediaMetadata; mediaMetadata = newMediaMetadata;
boolean playWhenReadyChanged = boolean playWhenReadyChanged =
@ -2361,7 +2366,7 @@ import java.util.concurrent.TimeoutException;
new MediaSourceList.MediaSourceHolder(mediaSources.get(i), useLazyPreparation); new MediaSourceList.MediaSourceHolder(mediaSources.get(i), useLazyPreparation);
holders.add(holder); holders.add(holder);
mediaSourceHolderSnapshots.add( mediaSourceHolderSnapshots.add(
i + index, new MediaSourceHolderSnapshot(holder.uid, holder.mediaSource.getTimeline())); i + index, new MediaSourceHolderSnapshot(holder.uid, holder.mediaSource));
} }
shuffleOrder = shuffleOrder =
shuffleOrder.cloneAndInsert( shuffleOrder.cloneAndInsert(
@ -2903,6 +2908,43 @@ import java.util.concurrent.TimeoutException;
} }
} }
private boolean canUpdateMediaSourcesWithMediaItems(
int fromIndex, int toIndex, List<MediaItem> 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<MediaItem> 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) { private static DeviceInfo createDeviceInfo(@Nullable StreamVolumeManager streamVolumeManager) {
return new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_LOCAL) return new DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_LOCAL)
.setMinVolume(streamVolumeManager != null ? streamVolumeManager.getMinVolume() : 0) .setMinVolume(streamVolumeManager != null ? streamVolumeManager.getMinVolume() : 0)
@ -2919,12 +2961,14 @@ import java.util.concurrent.TimeoutException;
private static final class MediaSourceHolderSnapshot implements MediaSourceInfoHolder { private static final class MediaSourceHolderSnapshot implements MediaSourceInfoHolder {
private final Object uid; private final Object uid;
private final MediaSource mediaSource;
private Timeline timeline; private Timeline timeline;
public MediaSourceHolderSnapshot(Object uid, Timeline timeline) { public MediaSourceHolderSnapshot(Object uid, MaskingMediaSource mediaSource) {
this.uid = uid; this.uid = uid;
this.timeline = timeline; this.mediaSource = mediaSource;
this.timeline = mediaSource.getTimeline();
} }
@Override @Override
@ -2936,6 +2980,10 @@ import java.util.concurrent.TimeoutException;
public Timeline getTimeline() { public Timeline getTimeline() {
return timeline; return timeline;
} }
public void updateTimeline(Timeline timeline) {
this.timeline = timeline;
}
} }
private final class ComponentListener private final class ComponentListener

View file

@ -33,6 +33,7 @@ import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.IllegalSeekPositionException; import androidx.media3.common.IllegalSeekPositionException;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Metadata; import androidx.media3.common.Metadata;
import androidx.media3.common.ParserException; import androidx.media3.common.ParserException;
import androidx.media3.common.PlaybackException; 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_SET_OFFLOAD_SCHEDULING_ENABLED = 24;
private static final int MSG_ATTEMPT_RENDERER_ERROR_RECOVERY = 25; private static final int MSG_ATTEMPT_RENDERER_ERROR_RECOVERY = 25;
private static final int MSG_RENDERER_CAPABILITIES_CHANGED = 26; 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 ACTIVE_INTERVAL_MS = 10;
private static final int IDLE_INTERVAL_MS = 1000; 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(); handler.obtainMessage(MSG_SET_SHUFFLE_ORDER, shuffleOrder).sendToTarget();
} }
public void updateMediaSourcesWithMediaItems(
int fromIndex, int toIndex, List<MediaItem> mediaItems) {
handler
.obtainMessage(MSG_UPDATE_MEDIA_SOURCES_WITH_MEDIA_ITEMS, fromIndex, toIndex, mediaItems)
.sendToTarget();
}
@Override @Override
public synchronized void sendMessage(PlayerMessage message) { public synchronized void sendMessage(PlayerMessage message) {
if (released || !playbackLooper.getThread().isAlive()) { if (released || !playbackLooper.getThread().isAlive()) {
@ -500,6 +509,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
// Handler.Callback implementation. // Handler.Callback implementation.
@SuppressWarnings("unchecked") // Casting message payload types.
@Override @Override
public boolean handleMessage(Message msg) { public boolean handleMessage(Message msg) {
try { try {
@ -587,6 +597,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
case MSG_RENDERER_CAPABILITIES_CHANGED: case MSG_RENDERER_CAPABILITIES_CHANGED:
reselectTracksInternalAndSeek(); reselectTracksInternalAndSeek();
break; break;
case MSG_UPDATE_MEDIA_SOURCES_WITH_MEDIA_ITEMS:
updateMediaSourcesWithMediaItemsInternal(msg.arg1, msg.arg2, (List<MediaItem>) msg.obj);
break;
case MSG_RELEASE: case MSG_RELEASE:
releaseInternal(); releaseInternal();
// Return immediately to not send playback info updates after release. // Return immediately to not send playback info updates after release.
@ -810,6 +823,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
handleMediaSourceListInfoRefreshed(timeline, /* isSourceRefresh= */ false); handleMediaSourceListInfoRefreshed(timeline, /* isSourceRefresh= */ false);
} }
private void updateMediaSourcesWithMediaItemsInternal(
int fromIndex, int toIndex, List<MediaItem> mediaItems) throws ExoPlaybackException {
playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1);
Timeline timeline =
mediaSourceList.updateMediaSourcesWithMediaItems(fromIndex, toIndex, mediaItems);
handleMediaSourceListInfoRefreshed(timeline, /* isSourceRefresh= */ false);
}
private void notifyTrackSelectionPlayWhenReadyChanged(boolean playWhenReady) { private void notifyTrackSelectionPlayWhenReadyChanged(boolean playWhenReady) {
MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); MediaPeriodHolder periodHolder = queue.getPlayingPeriod();
while (periodHolder != null) { while (periodHolder != null) {

View file

@ -22,6 +22,7 @@ import static java.lang.Math.min;
import android.os.Handler; import android.os.Handler;
import android.util.Pair; import android.util.Pair;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline; import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.HandlerWrapper; import androidx.media3.common.util.HandlerWrapper;
@ -250,6 +251,27 @@ import java.util.Set;
return createTimeline(); 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 &lt;= index &lt;= {@link #getSize()}.
* @param toIndex The index after the last media source to update. This index must be in the range
* of {@code fromIndex} &lt;= index &lt;= {@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<MediaItem> 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. */ /** Clears the playlist. */
public Timeline clear(@Nullable ShuffleOrder shuffleOrder) { public Timeline clear(@Nullable ShuffleOrder shuffleOrder) {
this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear(); this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear();

View file

@ -77,6 +77,23 @@ public final class MaskingMediaSource extends WrappingMediaSource {
return timeline; 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 @Override
public void prepareSourceInternal() { public void prepareSourceInternal() {
if (!useLazyPreparation) { if (!useLazyPreparation) {

View file

@ -280,6 +280,34 @@ public interface MediaSource {
@UnstableApi @UnstableApi
MediaItem getMediaItem(); MediaItem getMediaItem();
/**
* Returns whether the {@link MediaItem} for this source can be updated with the provided item.
*
* <p>Should not be called directly from application code.
*
* <p>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.
*
* <p>Should not be called directly from application code.
*
* <p>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)} * @deprecated Implement {@link #prepareSource(MediaSourceCaller, TransferListener, PlayerId)}
* instead. * instead.

View file

@ -167,6 +167,16 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
return mediaSources.length > 0 ? mediaSources[0].getMediaItem() : PLACEHOLDER_MEDIA_ITEM; 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 @Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener); super.prepareSourceInternal(mediaTransferListener);

View file

@ -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;
}
}

View file

@ -30,7 +30,9 @@ import androidx.media3.exoplayer.upstream.Allocator;
* *
* <ul> * <ul>
* <li>{@link #getMediaItem()}: Amend the {@link MediaItem} for this media source. This is only * <li>{@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}.
* <li>{@link #onChildSourceInfoRefreshed(Timeline)}: Called whenever the child source's {@link * <li>{@link #onChildSourceInfoRefreshed(Timeline)}: Called whenever the child source's {@link
* Timeline} changed. This {@link Timeline} can be amended if needed, for example using {@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 * ForwardingTimeline}. The {@link Timeline} for the wrapping source needs to be published
@ -88,18 +90,38 @@ public abstract class WrappingMediaSource extends CompositeMediaSource<Void> {
} }
/** /**
* Returns the {@link MediaItem} for this media source. * {@inheritDoc}
* *
* <p>This method can be overridden to amend the {@link MediaItem} of the child source. It is only * <p>This method can be overridden to amend the {@link MediaItem} of the child source. It is only
* used before the child source is prepared. * used before the child source is prepared.
*
* @see MediaSource#getMediaItem()
*/ */
@Override @Override
public MediaItem getMediaItem() { public MediaItem getMediaItem() {
return mediaSource.getMediaItem(); return mediaSource.getMediaItem();
} }
/**
* {@inheritDoc}
*
* <p>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}
*
* <p>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}. * Creates the requested {@link MediaPeriod}.
* *

View file

@ -191,6 +191,16 @@ public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
return contentMediaSource.getMediaItem(); return contentMediaSource.getMediaItem();
} }
@Override
public boolean canUpdateMediaItem(MediaItem mediaItem) {
return contentMediaSource.canUpdateMediaItem(mediaItem);
}
@Override
public void updateMediaItem(MediaItem mediaItem) {
contentMediaSource.updateMediaItem(mediaItem);
}
@Override @Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener); super.prepareSourceInternal(mediaTransferListener);

View file

@ -219,6 +219,16 @@ public final class ServerSideAdInsertionMediaSource extends BaseMediaSource
return mediaSource.getMediaItem(); return mediaSource.getMediaItem();
} }
@Override
public boolean canUpdateMediaItem(MediaItem mediaItem) {
return mediaSource.canUpdateMediaItem(mediaItem);
}
@Override
public void updateMediaItem(MediaItem mediaItem) {
mediaSource.updateMediaItem(mediaItem);
}
@Override @Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
Handler handler = Util.createHandlerForCurrentLooper(); Handler handler = Util.createHandlerForCurrentLooper();

View file

@ -87,6 +87,7 @@ import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf; import static org.robolectric.Shadows.shadowOf;
import android.content.Context; import android.content.Context;
@ -13140,6 +13141,202 @@ public final class ExoPlayerTest {
player.release(); 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 * Tests playback suppression for playback with only unsuitable outputs (e.g. builtin speaker) on
* the Wear OS. * the Wear OS.

View file

@ -40,6 +40,7 @@ import androidx.media3.test.utils.FakeMediaSource;
import androidx.media3.test.utils.FakeShuffleOrder; import androidx.media3.test.utils.FakeShuffleOrder;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -508,6 +509,40 @@ public class MediaSourceListTest {
mediaSourceList.setShuffleOrder(new FakeShuffleOrder(MEDIA_SOURCE_LIST_SIZE))); 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. // Internal methods.
private static void assertTimelineUsesFakeShuffleOrder(Timeline timeline) { private static void assertTimelineUsesFakeShuffleOrder(Timeline timeline) {

View file

@ -44,6 +44,7 @@ import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.exoplayer.source.MediaPeriod; import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.MediaSourceEventListener; import androidx.media3.exoplayer.source.MediaSourceEventListener;
import androidx.media3.exoplayer.source.TimelineWithUpdatedMediaItem;
import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.test.utils.FakeMediaPeriod.TrackDataFactory; import androidx.media3.test.utils.FakeMediaPeriod.TrackDataFactory;
@ -98,6 +99,7 @@ public class FakeMediaSource extends BaseMediaSource {
private final ArrayList<MediaPeriodId> createdMediaPeriods; private final ArrayList<MediaPeriodId> createdMediaPeriods;
private final DrmSessionManager drmSessionManager; private final DrmSessionManager drmSessionManager;
private boolean canUpdateMediaItems;
private boolean preparationAllowed; private boolean preparationAllowed;
private @MonotonicNonNull Timeline timeline; private @MonotonicNonNull Timeline timeline;
private boolean preparedSource; private boolean preparedSource;
@ -168,6 +170,7 @@ public class FakeMediaSource extends BaseMediaSource {
this.drmSessionManager = drmSessionManager; this.drmSessionManager = drmSessionManager;
this.trackDataFactory = trackDataFactory; this.trackDataFactory = trackDataFactory;
preparationAllowed = true; 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 @Nullable
protected Timeline getTimeline() { protected Timeline getTimeline() {
return timeline; return timeline;
@ -198,6 +210,22 @@ public class FakeMediaSource extends BaseMediaSource {
return timeline.getWindow(0, new Timeline.Window()).mediaItem; 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 @Override
@Nullable @Nullable
public Timeline getInitialTimeline() { public Timeline getInitialTimeline() {