From aa57d48347602c114f520ce5f3d6e18782f1af40 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 4 Jul 2023 15:08:35 +0000 Subject: [PATCH] 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 --- RELEASENOTES.md | 3 + .../androidx/media3/exoplayer/ExoPlayer.java | 21 ++ .../media3/exoplayer/ExoPlayerImpl.java | 64 +++++- .../exoplayer/ExoPlayerImplInternal.java | 21 ++ .../media3/exoplayer/MediaSourceList.java | 22 ++ .../exoplayer/source/MaskingMediaSource.java | 17 ++ .../media3/exoplayer/source/MediaSource.java | 28 +++ .../exoplayer/source/MergingMediaSource.java | 10 + .../source/TimelineWithUpdatedMediaItem.java | 50 +++++ .../exoplayer/source/WrappingMediaSource.java | 30 ++- .../exoplayer/source/ads/AdsMediaSource.java | 10 + .../ads/ServerSideAdInsertionMediaSource.java | 10 + .../media3/exoplayer/ExoPlayerTest.java | 197 ++++++++++++++++++ .../media3/exoplayer/MediaSourceListTest.java | 35 ++++ .../media3/test/utils/FakeMediaSource.java | 28 +++ 15 files changed, 534 insertions(+), 12 deletions(-) create mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/TimelineWithUpdatedMediaItem.java 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; * *