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.
* 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

View file

@ -1505,6 +1505,27 @@ public interface ExoPlayer extends Player {
@UnstableApi
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
* 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.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<MediaSource> 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<Timeline> 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<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) {
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

View file

@ -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<MediaItem> 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<MediaItem>) 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<MediaItem> 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) {

View file

@ -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 &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. */
public Timeline clear(@Nullable ShuffleOrder shuffleOrder) {
this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear();

View file

@ -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) {

View file

@ -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.
*
* <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)}
* instead.

View file

@ -167,6 +167,16 @@ public final class MergingMediaSource extends CompositeMediaSource<Integer> {
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);

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>
* <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
* 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<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
* used before the child source is prepared.
*
* @see MediaSource#getMediaItem()
*/
@Override
public MediaItem 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}.
*

View file

@ -191,6 +191,16 @@ public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> {
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);

View file

@ -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();

View file

@ -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.

View file

@ -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) {

View file

@ -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<MediaPeriodId> 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() {