diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5672cce75e..db545efa7e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -29,6 +29,9 @@ * Add `MediaSource.canUpdateMediaItem` and `MediaSource.updateMediaItem` to accept `MediaItem` updates after creation via `Player.replaceMediaItem(s)`. + * Allow `MediaItem` updates for all `MediaSource` classes provided by the + library via `Player.replaceMediaItem(s)` + (([#33](https://github.com/androidx/media/issues/33)),([#9978](https://github.com/google/ExoPlayer/issues/9978))). * 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/source/ClippingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java index 8576fd2fdd..8cf5cdb3c9 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java @@ -22,6 +22,7 @@ import static java.lang.annotation.ElementType.TYPE_USE; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.C; +import androidx.media3.common.MediaItem; import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.UnstableApi; @@ -194,6 +195,12 @@ public final class ClippingMediaSource extends WrappingMediaSource { window = new Timeline.Window(); } + @Override + public boolean canUpdateMediaItem(MediaItem mediaItem) { + return getMediaItem().clippingConfiguration.equals(mediaItem.clippingConfiguration) + && mediaSource.canUpdateMediaItem(mediaItem); + } + @Override public void maybeThrowSourceInfoRefreshError() throws IOException { if (clippingError != null) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2.java index 7aacffc6f7..fe3289308e 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2.java @@ -25,6 +25,7 @@ import android.net.Uri; import android.os.Handler; import android.os.Message; import android.util.Pair; +import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.MediaItem; @@ -211,13 +212,15 @@ public final class ConcatenatingMediaSource2 extends CompositeMediaSource mediaSourceHolders; private final IdentityHashMap mediaSourceByMediaPeriod; @Nullable private Handler playbackThreadHandler; private boolean timelineUpdateScheduled; + @GuardedBy("this") + private MediaItem mediaItem; + private ConcatenatingMediaSource2( MediaItem mediaItem, ImmutableList mediaSourceHolders) { this.mediaItem = mediaItem; @@ -232,10 +235,20 @@ public final class ConcatenatingMediaSource2 extends CompositeMediaSource { @Override public boolean canUpdateMediaItem(MediaItem mediaItem) { - return contentMediaSource.canUpdateMediaItem(mediaItem); + return Util.areEqual(getAdsConfiguration(getMediaItem()), getAdsConfiguration(mediaItem)) + && contentMediaSource.canUpdateMediaItem(mediaItem); } @Override @@ -370,6 +371,13 @@ public final class AdsMediaSource extends CompositeMediaSource { return adDurationsUs; } + @Nullable + private static MediaItem.AdsConfiguration getAdsConfiguration(MediaItem mediaItem) { + return mediaItem.localConfiguration == null + ? null + : mediaItem.localConfiguration.adsConfiguration; + } + /** Listener for component events. All methods are called on the main thread. */ private final class ComponentListener implements AdsLoader.EventListener { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java index b808fc8b89..9b30ffd14f 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer.source; +import static androidx.media3.common.util.Util.msToUs; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; @@ -25,6 +26,7 @@ import androidx.media3.common.Player; import androidx.media3.common.Timeline; import androidx.media3.common.Timeline.Period; import androidx.media3.common.Timeline.Window; +import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.source.ClippingMediaSource.IllegalClippingException; import androidx.media3.exoplayer.source.MaskingMediaSource.PlaceholderTimeline; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; @@ -32,9 +34,12 @@ import androidx.media3.test.utils.FakeMediaSource; import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.media3.test.utils.MediaSourceTestRunner; +import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.TimelineAsserts; +import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -479,6 +484,83 @@ public final class ClippingMediaSourceTest { TimelineAsserts.assertNextWindowIndices(clippedTimeline, Player.REPEAT_MODE_ALL, false, 0); } + @Test + public void canUpdateMediaItem_withIrrelevantFieldsChanged_returnsTrue() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setMediaId("id") + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder().setStartPositionMs(1).build()) + .build(); + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem() + .buildUpon() + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder().setStartPositionMs(1).build()) + .build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void canUpdateMediaItem_withChangedClippingConfiguration_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setMediaId("id") + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder().setStartPositionMs(1).build()) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setMediaId("id") + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder().setStartPositionMs(2).build()) + .build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test2.test").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + AtomicReference timelineReference = new AtomicReference<>(); + + mediaSource.updateMediaItem(updatedMediaItem); + mediaSource.prepareSource( + (source, timeline) -> timelineReference.set(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> timelineReference.get() != null); + + assertThat( + timelineReference + .get() + .getWindow(/* windowIndex= */ 0, new Timeline.Window()) + .mediaItem) + .isEqualTo(updatedMediaItem); + } + + private static MediaSource buildMediaSource(MediaItem mediaItem) { + FakeMediaSource fakeMediaSource = new FakeMediaSource(); + fakeMediaSource.setCanUpdateMediaItems(true); + fakeMediaSource.updateMediaItem(mediaItem); + return new ClippingMediaSource( + fakeMediaSource, + msToUs(mediaItem.clippingConfiguration.startPositionMs), + msToUs(mediaItem.clippingConfiguration.endPositionMs), + mediaItem.clippingConfiguration.startsAtKeyFrame, + mediaItem.clippingConfiguration.relativeToLiveWindow, + mediaItem.clippingConfiguration.relativeToDefaultPosition); + } + /** * Wraps the specified timeline in a {@link ClippingMediaSource} and returns the clipped timeline. */ diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java index 14d4e94306..85ad9ee398 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java @@ -46,6 +46,8 @@ import androidx.media3.exoplayer.util.EventLogger; import androidx.media3.test.utils.FakeMediaSource; import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.TestExoPlayerBuilder; +import androidx.media3.test.utils.TestUtil; +import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.core.app.ApplicationProvider; import com.google.common.base.Supplier; import com.google.common.collect.ImmutableList; @@ -691,6 +693,38 @@ public final class ConcatenatingMediaSource2Test { } } + @Test + public void canUpdateMediaItem_withFieldsChanged_returnsTrue() { + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem().buildUpon().setUri("http://test.test").build(); + MediaSource mediaSource = config.mediaSourceSupplier.get(); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaSource mediaSource = config.mediaSourceSupplier.get(); + AtomicReference timelineReference = new AtomicReference<>(); + + mediaSource.updateMediaItem(updatedMediaItem); + mediaSource.prepareSource( + (source, timeline) -> timelineReference.set(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> timelineReference.get() != null); + + assertThat( + timelineReference + .get() + .getWindow(/* windowIndex= */ 0, new Timeline.Window()) + .mediaItem) + .isEqualTo(updatedMediaItem); + } + private static void blockingPrepareMediaPeriod(MediaPeriod mediaPeriod) { ConditionVariable mediaPeriodPrepared = new ConditionVariable(); mediaPeriod.prepare( diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaSourceTest.java new file mode 100644 index 0000000000..3aeb8d4f28 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaSourceTest.java @@ -0,0 +1,117 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import androidx.media3.common.MediaItem; +import androidx.media3.common.Timeline; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.test.utils.TestUtil; +import androidx.media3.test.utils.robolectric.RobolectricUtil; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ProgressiveMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class ProgressiveMediaSourceTest { + + @Test + public void canUpdateMediaItem_withIrrelevantFieldsChanged_returnsTrue() { + MediaItem initialMediaItem = + new MediaItem.Builder().setUri("http://test.test").setCustomCacheKey("cache").build(); + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem() + .buildUpon() + .setUri("http://test.test") + .setCustomCacheKey("cache") + .build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void canUpdateMediaItem_withNullLocalConfiguration_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setMediaId("id").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedUri_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test2.test").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedCustomCacheKey_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder().setUri("http://test.test").setCustomCacheKey("old").build(); + MediaItem updatedMediaItem = + new MediaItem.Builder().setUri("http://test.test").setCustomCacheKey("new").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem initialMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag1").build(); + MediaItem updatedMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag2").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + AtomicReference timelineReference = new AtomicReference<>(); + + mediaSource.updateMediaItem(updatedMediaItem); + mediaSource.prepareSource( + (source, timeline) -> timelineReference.set(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> timelineReference.get() != null); + + assertThat( + timelineReference + .get() + .getWindow(/* windowIndex= */ 0, new Timeline.Window()) + .mediaItem) + .isEqualTo(updatedMediaItem); + } + + private static MediaSource buildMediaSource(MediaItem mediaItem) { + return new ProgressiveMediaSource.Factory( + new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext())) + .createMediaSource(mediaItem); + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SilenceMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SilenceMediaSourceTest.java index 1af7ef97d2..6618e1c473 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SilenceMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/SilenceMediaSourceTest.java @@ -21,7 +21,12 @@ import static org.junit.Assert.assertThrows; import android.net.Uri; import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; +import androidx.media3.common.Timeline; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.test.utils.TestUtil; +import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; import org.junit.runner.RunWith; @@ -85,4 +90,39 @@ public class SilenceMediaSourceTest { assertThat(mediaSource.getMediaItem().localConfiguration.uri).isEqualTo(Uri.EMPTY); assertThat(mediaItem.localConfiguration.mimeType).isEqualTo(MimeTypes.AUDIO_RAW); } + + @Test + public void canUpdateMediaItem_withFieldsChanged_returnsTrue() { + MediaItem updatedMediaItem = TestUtil.buildFullyCustomizedMediaItem(); + MediaSource mediaSource = buildMediaSource(); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaSource mediaSource = buildMediaSource(); + AtomicReference timelineReference = new AtomicReference<>(); + + mediaSource.updateMediaItem(updatedMediaItem); + mediaSource.prepareSource( + (source, timeline) -> timelineReference.set(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> timelineReference.get() != null); + + assertThat( + timelineReference + .get() + .getWindow(/* windowIndex= */ 0, new Timeline.Window()) + .mediaItem) + .isEqualTo(updatedMediaItem); + } + + private static MediaSource buildMediaSource() { + return new SilenceMediaSource.Factory().setDurationUs(1234).setTag("tag").createMediaSource(); + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java index 53366ff2ba..32e158ba14 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java @@ -18,11 +18,13 @@ package androidx.media3.exoplayer.source.ads; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; +import android.content.Context; import android.net.Uri; import android.os.Looper; import androidx.media3.common.AdPlaybackState; @@ -32,6 +34,7 @@ import androidx.media3.common.MediaItem; import androidx.media3.common.Timeline; import androidx.media3.datasource.DataSpec; import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaPeriod; import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; @@ -40,7 +43,11 @@ import androidx.media3.exoplayer.source.SinglePeriodTimeline; import androidx.media3.exoplayer.source.ads.AdsLoader.EventListener; import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.test.utils.FakeMediaSource; +import androidx.media3.test.utils.TestUtil; +import androidx.media3.test.utils.robolectric.RobolectricUtil; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -238,4 +245,90 @@ public final class AdsMediaSourceTest { prerollAdMediaSource.assertReleased(); contentMediaSource.assertReleased(); } + + @Test + public void canUpdateMediaItem_withIrrelevantFieldsChanged_returnsTrue() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.uri") + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.parse("http://ad.tag.test")).build()) + .build(); + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem() + .buildUpon() + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.parse("http://ad.tag.test")).build()) + .build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void canUpdateMediaItem_withChangedAdsConfiguration_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.uri") + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.parse("http://ad.tag.test")).build()) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri("http://test.uri") + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.parse("http://other.tag.test")).build()) + .build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test2.test").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + AtomicReference timelineReference = new AtomicReference<>(); + + mediaSource.updateMediaItem(updatedMediaItem); + mediaSource.prepareSource( + (source, timeline) -> timelineReference.set(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> timelineReference.get() != null); + + assertThat( + timelineReference + .get() + .getWindow(/* windowIndex= */ 0, new Timeline.Window()) + .mediaItem) + .isEqualTo(updatedMediaItem); + } + + private static MediaSource buildMediaSource(MediaItem mediaItem) { + FakeMediaSource fakeMediaSource = new FakeMediaSource(); + fakeMediaSource.setCanUpdateMediaItems(true); + fakeMediaSource.updateMediaItem(mediaItem); + AdsLoader adsLoader = mock(AdsLoader.class); + doAnswer( + method -> { + ((EventListener) method.getArgument(4)) + .onAdPlaybackState(new AdPlaybackState(TEST_ADS_ID)); + return null; + }) + .when(adsLoader) + .start(any(), any(), any(), any(), any()); + return new AdsMediaSource( + fakeMediaSource, + TEST_ADS_DATA_SPEC, + TEST_ADS_ID, + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()), + adsLoader, + /* adViewProvider= */ () -> null); + } } diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java index 71586a83b0..67e9c982b3 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java @@ -28,6 +28,7 @@ import android.os.Looper; import android.os.SystemClock; import android.text.TextUtils; import android.util.SparseArray; +import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.MediaItem; @@ -391,7 +392,6 @@ public final class DashMediaSource extends BaseMediaSource { private static final String TAG = "DashMediaSource"; - private final MediaItem mediaItem; private final boolean sideloadedManifest; private final DataSource.Factory manifestDataSourceFactory; private final DashChunkSource.Factory chunkSourceFactory; @@ -433,6 +433,9 @@ public final class DashMediaSource extends BaseMediaSource { private int firstPeriodId; + @GuardedBy("this") + private MediaItem mediaItem; + private DashMediaSource( MediaItem mediaItem, @Nullable DashManifest manifest, @@ -496,10 +499,28 @@ public final class DashMediaSource extends BaseMediaSource { // MediaSource implementation. @Override - public MediaItem getMediaItem() { + public synchronized MediaItem getMediaItem() { return mediaItem; } + @Override + public boolean canUpdateMediaItem(MediaItem mediaItem) { + MediaItem existingMediaItem = getMediaItem(); + MediaItem.LocalConfiguration existingConfiguration = + checkNotNull(existingMediaItem.localConfiguration); + @Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration; + return newConfiguration != null + && newConfiguration.uri.equals(existingConfiguration.uri) + && newConfiguration.streamKeys.equals(existingConfiguration.streamKeys) + && Util.areEqual(newConfiguration.drmConfiguration, existingConfiguration.drmConfiguration) + && existingMediaItem.liveConfiguration.equals(mediaItem.liveConfiguration); + } + + @Override + public synchronized void updateMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; @@ -902,7 +923,7 @@ public final class DashMediaSource extends BaseMediaSource { windowDurationUs, windowDefaultPositionUs, manifest, - mediaItem, + getMediaItem(), manifest.dynamic ? liveConfiguration : null); refreshSourceInfo(timeline); @@ -938,12 +959,13 @@ public final class DashMediaSource extends BaseMediaSource { } private void updateLiveConfiguration(long nowInWindowUs, long windowDurationUs) { + MediaItem.LiveConfiguration mediaItemLiveConfiguration = getMediaItem().liveConfiguration; // Default maximum offset: start of window. long maxPossibleLiveOffsetMs = usToMs(nowInWindowUs); long maxLiveOffsetMs = maxPossibleLiveOffsetMs; // Override maximum offset with user or media defined values if they are smaller. - if (mediaItem.liveConfiguration.maxOffsetMs != C.TIME_UNSET) { - maxLiveOffsetMs = min(maxLiveOffsetMs, mediaItem.liveConfiguration.maxOffsetMs); + if (mediaItemLiveConfiguration.maxOffsetMs != C.TIME_UNSET) { + maxLiveOffsetMs = min(maxLiveOffsetMs, mediaItemLiveConfiguration.maxOffsetMs); } else if (manifest.serviceDescription != null && manifest.serviceDescription.maxOffsetMs != C.TIME_UNSET) { maxLiveOffsetMs = min(maxLiveOffsetMs, manifest.serviceDescription.maxOffsetMs); @@ -961,10 +983,10 @@ public final class DashMediaSource extends BaseMediaSource { } // Override minimum offset with user and media defined values if they are larger, but don't // exceed the maximum possible offset. - if (mediaItem.liveConfiguration.minOffsetMs != C.TIME_UNSET) { + if (mediaItemLiveConfiguration.minOffsetMs != C.TIME_UNSET) { minLiveOffsetMs = constrainValue( - mediaItem.liveConfiguration.minOffsetMs, minLiveOffsetMs, maxPossibleLiveOffsetMs); + mediaItemLiveConfiguration.minOffsetMs, minLiveOffsetMs, maxPossibleLiveOffsetMs); } else if (manifest.serviceDescription != null && manifest.serviceDescription.minOffsetMs != C.TIME_UNSET) { minLiveOffsetMs = @@ -1000,14 +1022,14 @@ public final class DashMediaSource extends BaseMediaSource { maxTargetOffsetForSafeDistanceToWindowStartMs, minLiveOffsetMs, maxLiveOffsetMs); } float minPlaybackSpeed = C.RATE_UNSET; - if (mediaItem.liveConfiguration.minPlaybackSpeed != C.RATE_UNSET) { - minPlaybackSpeed = mediaItem.liveConfiguration.minPlaybackSpeed; + if (mediaItemLiveConfiguration.minPlaybackSpeed != C.RATE_UNSET) { + minPlaybackSpeed = mediaItemLiveConfiguration.minPlaybackSpeed; } else if (manifest.serviceDescription != null) { minPlaybackSpeed = manifest.serviceDescription.minPlaybackSpeed; } float maxPlaybackSpeed = C.RATE_UNSET; - if (mediaItem.liveConfiguration.maxPlaybackSpeed != C.RATE_UNSET) { - maxPlaybackSpeed = mediaItem.liveConfiguration.maxPlaybackSpeed; + if (mediaItemLiveConfiguration.maxPlaybackSpeed != C.RATE_UNSET) { + maxPlaybackSpeed = mediaItemLiveConfiguration.maxPlaybackSpeed; } else if (manifest.serviceDescription != null) { maxPlaybackSpeed = manifest.serviceDescription.maxPlaybackSpeed; } diff --git a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaSourceTest.java b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaSourceTest.java index 4cca2aee2e..37eec2a0b1 100644 --- a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaSourceTest.java +++ b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaSourceTest.java @@ -16,7 +16,6 @@ package androidx.media3.exoplayer.dash; import static com.google.common.truth.Truth.assertThat; -import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.Assert.fail; import android.net.Uri; @@ -24,6 +23,7 @@ import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem.LiveConfiguration; import androidx.media3.common.ParserException; +import androidx.media3.common.StreamKey; import androidx.media3.common.Timeline; import androidx.media3.common.Timeline.Window; import androidx.media3.common.util.Util; @@ -32,18 +32,17 @@ import androidx.media3.datasource.DataSource; import androidx.media3.datasource.FileDataSource; import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.MediaSource.MediaSourceCaller; import androidx.media3.exoplayer.upstream.ParsingLoadable; import androidx.media3.test.utils.TestUtil; +import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.shadows.ShadowLooper; /** Unit test for {@link DashMediaSource}. */ @RunWith(AndroidJUnit4.class) @@ -142,7 +141,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withoutLiveConfiguration_withoutMediaItemLiveConfiguration_usesUnitSpeed() - throws InterruptedException { + throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) @@ -161,7 +160,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withoutLiveConfiguration_withOnlyMediaItemTargetOffset_usesUnitSpeed() - throws InterruptedException { + throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) @@ -184,7 +183,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withoutLiveConfiguration_withMediaItemSpeedLimits_usesDefaultFallbackValues() - throws InterruptedException { + throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) @@ -209,7 +208,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withoutLiveConfiguration_withoutMediaItemTargetOffset_usesDefinedFallbackTargetOffset() - throws InterruptedException { + throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) @@ -233,7 +232,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withoutLiveConfiguration_withMediaItemLiveProperties_usesMediaItem() - throws InterruptedException { + throws Exception { MediaItem mediaItem = new MediaItem.Builder() .setUri(Uri.EMPTY) @@ -260,7 +259,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withSuggestedPresentationDelayAndMinBufferTime_usesManifestValue() - throws InterruptedException { + throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> @@ -287,7 +286,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withSuggestedPresentationDelayAndMinBufferTime_withMediaItemLiveProperties_usesMediaItem() - throws InterruptedException { + throws Exception { MediaItem mediaItem = new MediaItem.Builder() .setUri(Uri.EMPTY) @@ -319,8 +318,7 @@ public final class DashMediaSourceTest { } @Test - public void prepare_withCompleteServiceDescription_usesManifestValue() - throws InterruptedException { + public void prepare_withCompleteServiceDescription_usesManifestValue() throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITH_COMPLETE_SERVICE_DESCRIPTION)) @@ -339,7 +337,7 @@ public final class DashMediaSourceTest { @Test public void prepare_withCompleteServiceDescription_withMediaItemLiveProperties_usesMediaItem() - throws InterruptedException { + throws Exception { MediaItem mediaItem = new MediaItem.Builder() .setUri(Uri.EMPTY) @@ -397,7 +395,7 @@ public final class DashMediaSourceTest { @Test public void prepare_targetLiveOffsetInWindow_manifestTargetOffsetAndAlignedWindowStartPosition() - throws InterruptedException { + throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITH_OFFSET_INSIDE_WINDOW)) @@ -413,7 +411,7 @@ public final class DashMediaSourceTest { @Test public void prepare_targetLiveOffsetTooLong_correctedTargetOffsetAndAlignedWindowStartPosition() - throws InterruptedException { + throws Exception { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITH_OFFSET_TOO_LONG)) @@ -429,7 +427,7 @@ public final class DashMediaSourceTest { @Test public void prepare_targetLiveOffsetTooShort_correctedTargetOffsetAndAlignedWindowStartPosition() - throws InterruptedException { + throws Exception { // Load manifest with now time far behind the start of the window. DashMediaSource mediaSource = new DashMediaSource.Factory( @@ -444,21 +442,156 @@ public final class DashMediaSourceTest { assertThat(window.liveConfiguration.targetOffsetMs).isEqualTo(60_000 - 16_000); } - private static Window prepareAndWaitForTimelineRefresh(MediaSource mediaSource) - throws InterruptedException { - AtomicReference windowReference = new AtomicReference<>(); - CountDownLatch countDownLatch = new CountDownLatch(/* count= */ 1); - MediaSourceCaller caller = - (MediaSource source, Timeline timeline) -> { - if (windowReference.get() == null) { - windowReference.set(timeline.getWindow(0, new Timeline.Window())); - countDownLatch.countDown(); - } - }; - mediaSource.prepareSource(caller, /* mediaTransferListener= */ null, PlayerId.UNSET); - while (!countDownLatch.await(/* timeout= */ 10, MILLISECONDS)) { - ShadowLooper.idleMainLooper(); - } + @Test + public void canUpdateMediaItem_withIrrelevantFieldsChanged_returnsTrue() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .setLiveConfiguration(new LiveConfiguration.Builder().setTargetOffsetMs(2000).build()) + .build(); + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem() + .buildUpon() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .setLiveConfiguration(new LiveConfiguration.Builder().setTargetOffsetMs(2000).build()) + .build(); + MediaSource mediaSource = + new DashMediaSource.Factory( + () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void canUpdateMediaItem_withNullLocalConfiguration_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setMediaId("id").build(); + MediaSource mediaSource = + new DashMediaSource.Factory( + () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedUri_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test2.test").build(); + MediaSource mediaSource = + new DashMediaSource.Factory( + () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedStreamKeys_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 2, /* streamIndex= */ 2))) + .build(); + MediaSource mediaSource = + new DashMediaSource.Factory( + () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedDrmConfiguration_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build()) + .build(); + MediaSource mediaSource = + new DashMediaSource.Factory( + () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedLiveConfiguration_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setLiveConfiguration(new LiveConfiguration.Builder().setTargetOffsetMs(2000).build()) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setLiveConfiguration(new LiveConfiguration.Builder().setTargetOffsetMs(5000).build()) + .build(); + MediaSource mediaSource = + new DashMediaSource.Factory( + () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem initialMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag1").build(); + MediaItem updatedMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag2").build(); + MediaSource mediaSource = + new DashMediaSource.Factory( + () -> createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION)) + .createMediaSource(initialMediaItem); + + mediaSource.updateMediaItem(updatedMediaItem); + Timeline.Window window = prepareAndWaitForTimelineRefresh(mediaSource); + + assertThat(window.mediaItem).isEqualTo(updatedMediaItem); + } + + private static Window prepareAndWaitForTimelineRefresh(MediaSource mediaSource) throws Exception { + AtomicReference windowReference = new AtomicReference<>(); + mediaSource.prepareSource( + (source, timeline) -> + windowReference.set(timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window())), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> windowReference.get() != null); return windowReference.get(); } diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java index 08144895fb..3c49efbae0 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java @@ -21,6 +21,7 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import android.os.Looper; import android.os.SystemClock; +import androidx.annotation.GuardedBy; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -400,7 +401,6 @@ public final class HlsMediaSource extends BaseMediaSource } private final HlsExtractorFactory extractorFactory; - private final MediaItem.LocalConfiguration localConfiguration; private final HlsDataSourceFactory dataSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; @Nullable private final CmcdConfiguration cmcdConfiguration; @@ -411,12 +411,14 @@ public final class HlsMediaSource extends BaseMediaSource private final boolean useSessionKeys; private final HlsPlaylistTracker playlistTracker; private final long elapsedRealTimeOffsetMs; - private final MediaItem mediaItem; private final long timestampAdjusterInitializationTimeoutMs; private MediaItem.LiveConfiguration liveConfiguration; @Nullable private TransferListener mediaTransferListener; + @GuardedBy("this") + private MediaItem mediaItem; + private HlsMediaSource( MediaItem mediaItem, HlsDataSourceFactory dataSourceFactory, @@ -431,7 +433,6 @@ public final class HlsMediaSource extends BaseMediaSource @MetadataType int metadataType, boolean useSessionKeys, long timestampAdjusterInitializationTimeoutMs) { - this.localConfiguration = checkNotNull(mediaItem.localConfiguration); this.mediaItem = mediaItem; this.liveConfiguration = mediaItem.liveConfiguration; this.dataSourceFactory = dataSourceFactory; @@ -449,10 +450,28 @@ public final class HlsMediaSource extends BaseMediaSource } @Override - public MediaItem getMediaItem() { + public synchronized MediaItem getMediaItem() { return mediaItem; } + @Override + public boolean canUpdateMediaItem(MediaItem mediaItem) { + MediaItem existingMediaItem = getMediaItem(); + MediaItem.LocalConfiguration existingConfiguration = + checkNotNull(existingMediaItem.localConfiguration); + @Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration; + return newConfiguration != null + && newConfiguration.uri.equals(existingConfiguration.uri) + && newConfiguration.streamKeys.equals(existingConfiguration.streamKeys) + && Util.areEqual(newConfiguration.drmConfiguration, existingConfiguration.drmConfiguration) + && existingMediaItem.liveConfiguration.equals(mediaItem.liveConfiguration); + } + + @Override + public synchronized void updateMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; @@ -462,7 +481,9 @@ public final class HlsMediaSource extends BaseMediaSource MediaSourceEventListener.EventDispatcher eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); playlistTracker.start( - localConfiguration.uri, eventDispatcher, /* primaryPlaylistListener= */ this); + checkNotNull(getMediaItem().localConfiguration).uri, + eventDispatcher, + /* primaryPlaylistListener= */ this); } @Override @@ -567,7 +588,7 @@ public final class HlsMediaSource extends BaseMediaSource /* isDynamic= */ !playlist.hasEndTag, suppressPositionProjection, manifest, - mediaItem, + getMediaItem(), liveConfiguration); } @@ -600,7 +621,7 @@ public final class HlsMediaSource extends BaseMediaSource /* isDynamic= */ false, /* suppressPositionProjection= */ true, manifest, - mediaItem, + getMediaItem(), /* liveConfiguration= */ null); } @@ -640,9 +661,10 @@ public final class HlsMediaSource extends BaseMediaSource } private void updateLiveConfiguration(HlsMediaPlaylist playlist, long targetLiveOffsetUs) { + MediaItem.LiveConfiguration mediaItemLiveConfiguration = getMediaItem().liveConfiguration; boolean disableSpeedAdjustment = - mediaItem.liveConfiguration.minPlaybackSpeed == C.RATE_UNSET - && mediaItem.liveConfiguration.maxPlaybackSpeed == C.RATE_UNSET + mediaItemLiveConfiguration.minPlaybackSpeed == C.RATE_UNSET + && mediaItemLiveConfiguration.maxPlaybackSpeed == C.RATE_UNSET && playlist.serverControl.holdBackUs == C.TIME_UNSET && playlist.serverControl.partHoldBackUs == C.TIME_UNSET; liveConfiguration = diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaSourceTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaSourceTest.java index 7e3f637fa3..fe0651c776 100644 --- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaSourceTest.java +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaSourceTest.java @@ -23,6 +23,7 @@ import android.os.SystemClock; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.ParserException; +import androidx.media3.common.StreamKey; import androidx.media3.common.Timeline; import androidx.media3.common.util.Util; import androidx.media3.exoplayer.analytics.PlayerId; @@ -31,7 +32,9 @@ import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser; import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.test.utils.FakeDataSet; import androidx.media3.test.utils.FakeDataSource; +import androidx.media3.test.utils.TestUtil; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.ArrayList; @@ -775,6 +778,186 @@ public class HlsMediaSourceTest { .isEqualTo(8000); } + @Test + public void canUpdateMediaItem_withIrrelevantFieldsChanged_returnsTrue() { + String playlistUri = "http://test.test"; + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri(playlistUri) + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(2000).build()) + .build(); + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem() + .buildUpon() + .setUri(playlistUri) + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(2000).build()) + .build(); + String playlist = + "#EXTM3U\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-ENDLIST"; + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + HlsMediaSource mediaSource = factory.createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void canUpdateMediaItem_withNullLocalConfiguration_returnsFalse() { + String playlistUri = "http://test.test"; + MediaItem initialMediaItem = new MediaItem.Builder().setUri(playlistUri).build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setMediaId("id").build(); + String playlist = + "#EXTM3U\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-ENDLIST"; + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + HlsMediaSource mediaSource = factory.createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedUri_returnsFalse() { + String playlistUri = "http://test.test"; + MediaItem initialMediaItem = new MediaItem.Builder().setUri(playlistUri).build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test2.test").build(); + String playlist = + "#EXTM3U\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-ENDLIST"; + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + HlsMediaSource mediaSource = factory.createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedStreamKeys_returnsFalse() { + String playlistUri = "http://test.test"; + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri(playlistUri) + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri(playlistUri) + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 2, /* streamIndex= */ 2))) + .build(); + String playlist = + "#EXTM3U\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-ENDLIST"; + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + HlsMediaSource mediaSource = factory.createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedDrmConfiguration_returnsFalse() { + String playlistUri = "http://test.test"; + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri(playlistUri) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri(playlistUri) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build()) + .build(); + String playlist = + "#EXTM3U\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-ENDLIST"; + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + HlsMediaSource mediaSource = factory.createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedLiveConfiguration_returnsFalse() { + String playlistUri = "http://test.test"; + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri(playlistUri) + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(2000).build()) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri(playlistUri) + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(5000).build()) + .build(); + String playlist = + "#EXTM3U\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-ENDLIST"; + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + HlsMediaSource mediaSource = factory.createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + String playlistUri = "http://test.test"; + MediaItem initialMediaItem = new MediaItem.Builder().setUri(playlistUri).setTag("tag1").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri(playlistUri).setTag("tag2").build(); + String playlist = + "#EXTM3U\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-TARGETDURATION:10\n" + + "#EXT-X-VERSION:4\n" + + "#EXT-X-ENDLIST"; + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + HlsMediaSource mediaSource = factory.createMediaSource(initialMediaItem); + + mediaSource.updateMediaItem(updatedMediaItem); + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + assertThat(timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).mediaItem) + .isEqualTo(updatedMediaItem); + } + private static HlsMediaSource.Factory createHlsMediaSourceFactory( String playlistUri, String playlist) { FakeDataSet fakeDataSet = new FakeDataSet().setData(playlistUri, Util.getUtf8Bytes(playlist)); diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java index 7c2751ce91..1d832a4fcc 100644 --- a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java @@ -44,6 +44,7 @@ import android.os.Handler; import android.os.Looper; import android.util.Pair; import android.view.ViewGroup; +import androidx.annotation.GuardedBy; import androidx.annotation.MainThread; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -499,7 +500,6 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou private static final String TAG = "ImaSSAIMediaSource"; - private final MediaItem mediaItem; private final Player player; private final MediaSource.Factory contentMediaSourceFactory; private final AdsLoader adsLoader; @@ -521,6 +521,9 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou @Nullable private Timeline contentTimeline; private AdPlaybackState adPlaybackState; + @GuardedBy("this") + private MediaItem mediaItem; + private ImaServerSideAdInsertionMediaSource( Player player, MediaItem mediaItem, @@ -559,10 +562,29 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou } @Override - public MediaItem getMediaItem() { + public synchronized MediaItem getMediaItem() { return mediaItem; } + @Override + public boolean canUpdateMediaItem(MediaItem mediaItem) { + MediaItem existingMediaItem = getMediaItem(); + MediaItem.LocalConfiguration existingConfiguration = + checkNotNull(existingMediaItem.localConfiguration); + @Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration; + return newConfiguration != null + && newConfiguration.uri.equals(existingConfiguration.uri) + && newConfiguration.streamKeys.equals(existingConfiguration.streamKeys) + && Util.areEqual(newConfiguration.customCacheKey, existingConfiguration.customCacheKey) + && Util.areEqual(newConfiguration.drmConfiguration, existingConfiguration.drmConfiguration) + && existingMediaItem.liveConfiguration.equals(mediaItem.liveConfiguration); + } + + @Override + public synchronized void updateMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + } + @Override public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { mainHandler.post(() -> assertSingleInstanceInPlaylist(checkNotNull(player))); @@ -588,6 +610,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou @Override protected void onChildSourceInfoRefreshed( Void childSourceId, MediaSource mediaSource, Timeline newTimeline) { + MediaItem mediaItem = getMediaItem(); refreshSourceInfo( new ForwardingTimeline(newTimeline) { @Override @@ -728,6 +751,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou private void setContentUri(Uri contentUri) { if (serverSideAdInsertionMediaSource == null) { + MediaItem mediaItem = getMediaItem(); MediaItem contentMediaItem = new MediaItem.Builder() .setUri(contentUri) @@ -835,6 +859,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou return; } + MediaItem mediaItem = getMediaItem(); if (mediaItem.equals(oldPosition.mediaItem) && !mediaItem.equals(newPosition.mediaItem)) { // Playback automatically transitioned to the next media item. Notify the SDK. streamPlayer.onContentCompleted(); @@ -906,7 +931,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou @Override public void onMetadata(Metadata metadata) { - if (!isCurrentAdPlaying(player, mediaItem, adsId)) { + if (!isCurrentAdPlaying(player, getMediaItem(), adsId)) { return; } for (int i = 0; i < metadata.length(); i++) { @@ -926,14 +951,14 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou @Override public void onPlaybackStateChanged(@Player.State int state) { - if (state == Player.STATE_ENDED && isCurrentAdPlaying(player, mediaItem, adsId)) { + if (state == Player.STATE_ENDED && isCurrentAdPlaying(player, getMediaItem(), adsId)) { streamPlayer.onContentCompleted(); } } @Override public void onVolumeChanged(float volume) { - if (!isCurrentAdPlaying(player, mediaItem, adsId)) { + if (!isCurrentAdPlaying(player, getMediaItem(), adsId)) { return; } int volumePct = (int) Math.floor(volume * 100); diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java index 0c69fe0df9..965ac0e10a 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java @@ -20,6 +20,7 @@ import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import android.net.Uri; +import androidx.annotation.GuardedBy; import androidx.annotation.IntRange; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -213,7 +214,6 @@ public final class RtspMediaSource extends BaseMediaSource { } } - private final MediaItem mediaItem; private final RtpDataChannel.Factory rtpDataChannelFactory; private final String userAgent; private final Uri uri; @@ -225,6 +225,9 @@ public final class RtspMediaSource extends BaseMediaSource { private boolean timelineIsLive; private boolean timelineIsPlaceholder; + @GuardedBy("this") + private MediaItem mediaItem; + @VisibleForTesting /* package */ RtspMediaSource( MediaItem mediaItem, @@ -235,7 +238,7 @@ public final class RtspMediaSource extends BaseMediaSource { this.mediaItem = mediaItem; this.rtpDataChannelFactory = rtpDataChannelFactory; this.userAgent = userAgent; - this.uri = checkNotNull(this.mediaItem.localConfiguration).uri; + this.uri = checkNotNull(mediaItem.localConfiguration).uri; this.socketFactory = socketFactory; this.debugLoggingEnabled = debugLoggingEnabled; this.timelineDurationUs = C.TIME_UNSET; @@ -253,10 +256,21 @@ public final class RtspMediaSource extends BaseMediaSource { } @Override - public MediaItem getMediaItem() { + public synchronized MediaItem getMediaItem() { return mediaItem; } + @Override + public boolean canUpdateMediaItem(MediaItem mediaItem) { + @Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration; + return newConfiguration != null && newConfiguration.uri.equals(this.uri); + } + + @Override + public synchronized void updateMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + } + @Override public void maybeThrowSourceInfoRefreshError() { // Do nothing. @@ -304,7 +318,7 @@ public final class RtspMediaSource extends BaseMediaSource { /* isDynamic= */ false, /* useLiveConfiguration= */ timelineIsLive, /* manifest= */ null, - mediaItem); + getMediaItem()); if (timelineIsPlaceholder) { timeline = new ForwardingTimeline(timeline) { diff --git a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspMediaSourceTest.java b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspMediaSourceTest.java new file mode 100644 index 0000000000..2da542a5ab --- /dev/null +++ b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspMediaSourceTest.java @@ -0,0 +1,96 @@ +/* + * 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.rtsp; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.media3.common.MediaItem; +import androidx.media3.common.Timeline; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.test.utils.TestUtil; +import androidx.media3.test.utils.robolectric.RobolectricUtil; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtspMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class RtspMediaSourceTest { + + @Test + public void canUpdateMediaItem_withIrrelevantFieldsChanged_returnsTrue() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem().buildUpon().setUri("http://test.test").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void canUpdateMediaItem_withNullLocalConfiguration_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setMediaId("id").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedUri_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test2.test").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem initialMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag1").build(); + MediaItem updatedMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag2").build(); + MediaSource mediaSource = buildMediaSource(initialMediaItem); + AtomicReference timelineReference = new AtomicReference<>(); + + mediaSource.updateMediaItem(updatedMediaItem); + mediaSource.prepareSource( + (source, timeline) -> timelineReference.set(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> timelineReference.get() != null); + + assertThat( + timelineReference + .get() + .getWindow(/* windowIndex= */ 0, new Timeline.Window()) + .mediaItem) + .isEqualTo(updatedMediaItem); + } + + private static MediaSource buildMediaSource(MediaItem mediaItem) { + return new RtspMediaSource.Factory().createMediaSource(mediaItem); + } +} diff --git a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java index ac4aa92760..1240869dad 100644 --- a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java +++ b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java @@ -23,6 +23,7 @@ import android.net.Uri; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; +import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.MediaItem; @@ -334,8 +335,6 @@ public final class SsMediaSource extends BaseMediaSource private final boolean sideloadedManifest; private final Uri manifestUri; - private final MediaItem.LocalConfiguration localConfiguration; - private final MediaItem mediaItem; private final DataSource.Factory manifestDataSourceFactory; private final SsChunkSource.Factory chunkSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; @@ -351,12 +350,13 @@ public final class SsMediaSource extends BaseMediaSource private Loader manifestLoader; private LoaderErrorThrower manifestLoaderErrorThrower; @Nullable private TransferListener mediaTransferListener; - private long manifestLoadStartTimestamp; private SsManifest manifest; - private Handler manifestRefreshHandler; + @GuardedBy("this") + private MediaItem mediaItem; + private SsMediaSource( MediaItem mediaItem, @Nullable SsManifest manifest, @@ -370,7 +370,7 @@ public final class SsMediaSource extends BaseMediaSource long livePresentationDelayMs) { Assertions.checkState(manifest == null || !manifest.isLive); this.mediaItem = mediaItem; - localConfiguration = checkNotNull(mediaItem.localConfiguration); + MediaItem.LocalConfiguration localConfiguration = checkNotNull(mediaItem.localConfiguration); this.manifest = manifest; this.manifestUri = localConfiguration.uri.equals(Uri.EMPTY) @@ -392,10 +392,26 @@ public final class SsMediaSource extends BaseMediaSource // MediaSource implementation. @Override - public MediaItem getMediaItem() { + public synchronized MediaItem getMediaItem() { return mediaItem; } + @Override + public boolean canUpdateMediaItem(MediaItem mediaItem) { + MediaItem.LocalConfiguration existingConfiguration = + checkNotNull(getMediaItem().localConfiguration); + @Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration; + return newConfiguration != null + && newConfiguration.uri.equals(existingConfiguration.uri) + && newConfiguration.streamKeys.equals(existingConfiguration.streamKeys) + && Util.areEqual(newConfiguration.drmConfiguration, existingConfiguration.drmConfiguration); + } + + @Override + public synchronized void updateMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; @@ -567,7 +583,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ manifest.isLive, /* useLiveConfiguration= */ manifest.isLive, manifest, - mediaItem); + getMediaItem()); } else if (manifest.isLive) { if (manifest.dvrWindowLengthUs != C.TIME_UNSET && manifest.dvrWindowLengthUs > 0) { startTimeUs = max(startTimeUs, endTimeUs - manifest.dvrWindowLengthUs); @@ -590,7 +606,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ true, /* useLiveConfiguration= */ true, manifest, - mediaItem); + getMediaItem()); } else { long durationUs = manifest.durationUs != C.TIME_UNSET ? manifest.durationUs : endTimeUs - startTimeUs; @@ -604,7 +620,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ false, /* useLiveConfiguration= */ false, manifest, - mediaItem); + getMediaItem()); } refreshSourceInfo(timeline); } diff --git a/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSourceTest.java b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSourceTest.java new file mode 100644 index 0000000000..34f9cad303 --- /dev/null +++ b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSourceTest.java @@ -0,0 +1,178 @@ +/* + * 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.smoothstreaming; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.StreamKey; +import androidx.media3.common.Timeline; +import androidx.media3.datasource.ByteArrayDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.test.utils.TestUtil; +import androidx.media3.test.utils.robolectric.RobolectricUtil; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link SsMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class SsMediaSourceTest { + + private static final String SAMPLE_MANIFEST = "media/smooth-streaming/sample_ismc_1"; + + @Test + public void canUpdateMediaItem_withIrrelevantFieldsChanged_returnsTrue() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .build(); + MediaItem updatedMediaItem = + TestUtil.buildFullyCustomizedMediaItem() + .buildUpon() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .build(); + MediaSource mediaSource = + new SsMediaSource.Factory(() -> createSampleDataSource(SAMPLE_MANIFEST)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isTrue(); + } + + @Test + public void canUpdateMediaItem_withNullLocalConfiguration_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setMediaId("id").build(); + MediaSource mediaSource = + new SsMediaSource.Factory(() -> createSampleDataSource(SAMPLE_MANIFEST)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedUri_returnsFalse() { + MediaItem initialMediaItem = new MediaItem.Builder().setUri("http://test.test").build(); + MediaItem updatedMediaItem = new MediaItem.Builder().setUri("http://test2.test").build(); + MediaSource mediaSource = + new SsMediaSource.Factory(() -> createSampleDataSource(SAMPLE_MANIFEST)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedStreamKeys_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* streamIndex= */ 0))) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 2, /* streamIndex= */ 2))) + .build(); + MediaSource mediaSource = + new SsMediaSource.Factory(() -> createSampleDataSource(SAMPLE_MANIFEST)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void canUpdateMediaItem_withChangedDrmConfiguration_returnsFalse() { + MediaItem initialMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()) + .build(); + MediaItem updatedMediaItem = + new MediaItem.Builder() + .setUri("http://test.test") + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build()) + .build(); + MediaSource mediaSource = + new SsMediaSource.Factory(() -> createSampleDataSource(SAMPLE_MANIFEST)) + .createMediaSource(initialMediaItem); + + boolean canUpdateMediaItem = mediaSource.canUpdateMediaItem(updatedMediaItem); + + assertThat(canUpdateMediaItem).isFalse(); + } + + @Test + public void updateMediaItem_createsTimelineWithUpdatedItem() throws Exception { + MediaItem initialMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag1").build(); + MediaItem updatedMediaItem = + new MediaItem.Builder().setUri("http://test.test").setTag("tag2").build(); + MediaSource mediaSource = + new SsMediaSource.Factory(() -> createSampleDataSource(SAMPLE_MANIFEST)) + .createMediaSource(initialMediaItem); + + mediaSource.updateMediaItem(updatedMediaItem); + Timeline.Window window = prepareAndWaitForTimelineRefresh(mediaSource); + + assertThat(window.mediaItem).isEqualTo(updatedMediaItem); + } + + private static Timeline.Window prepareAndWaitForTimelineRefresh(MediaSource mediaSource) + throws Exception { + AtomicReference windowReference = new AtomicReference<>(); + mediaSource.prepareSource( + (source, timeline) -> + windowReference.set(timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window())), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + RobolectricUtil.runMainLooperUntil(() -> windowReference.get() != null); + return windowReference.get(); + } + + private static DataSource createSampleDataSource(String fileName) { + byte[] manifestData = new byte[0]; + try { + manifestData = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), fileName); + } catch (IOException e) { + fail(e.getMessage()); + } + return new ByteArrayDataSource(manifestData); + } +} diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java index 2d23500ab4..a016a10e48 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java @@ -27,6 +27,9 @@ import android.media.MediaCodec; import android.net.Uri; import androidx.annotation.Nullable; import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaMetadata; +import androidx.media3.common.StreamKey; import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.UnstableApi; @@ -63,6 +66,7 @@ import java.util.List; import java.util.Queue; import java.util.Random; import java.util.Set; +import java.util.UUID; /** Utility methods for tests. */ @UnstableApi @@ -562,6 +566,33 @@ public class TestUtil { return list; } + /** Returns a {@link MediaItem} that has all fields set to non-default values. */ + public static MediaItem buildFullyCustomizedMediaItem() { + return new MediaItem.Builder() + .setUri("http://custom.uri.test") + .setCustomCacheKey("custom.cache") + .setMediaId("custom.id") + .setMediaMetadata(new MediaMetadata.Builder().setTitle("custom.title").build()) + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder().setStartPositionMs(123).build()) + .setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.parse("http:://custom.ad.test")).build()) + .setDrmConfiguration(new MediaItem.DrmConfiguration.Builder(UUID.randomUUID()).build()) + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder().setTargetOffsetMs(234).build()) + .setMimeType("mime") + .setRequestMetadata( + new MediaItem.RequestMetadata.Builder().setSearchQuery("custom.query").build()) + .setStreamKeys(ImmutableList.of(new StreamKey(/* groupIndex= */ 0, /* streamIndex= */ 0))) + .setTag("tag") + .setSubtitleConfigurations( + ImmutableList.of( + new MediaItem.SubtitleConfiguration.Builder( + Uri.parse("http://custom.subtitle.test")) + .build())) + .build(); + } + private static final class NoUidOrShufflingTimeline extends Timeline { private final Timeline delegate;