From be63e156bbdb656df4792fb5c211d9a87f51195e Mon Sep 17 00:00:00 2001 From: michaelkatz Date: Mon, 9 Dec 2024 12:39:24 -0800 Subject: [PATCH] Implement mediasource list update support for pre-warming renderers PiperOrigin-RevId: 704381778 --- .../exoplayer/ExoPlayerImplInternal.java | 53 ++- .../media3/exoplayer/MediaPeriodQueue.java | 138 +++++--- .../ExoPlayerWithPrewarmingRenderersTest.java | 304 ++++++++++++++++++ .../exoplayer/MediaPeriodQueueTest.java | 103 ++++-- 4 files changed, 514 insertions(+), 84 deletions(-) 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 a5afa27f0f..b661bf52bc 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -19,7 +19,8 @@ import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Util.castNonNull; import static androidx.media3.common.util.Util.msToUs; -import static androidx.media3.exoplayer.MediaPeriodQueue.REMOVE_AFTER_REMOVED_READING_PERIOD; +import static androidx.media3.exoplayer.MediaPeriodQueue.UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD; +import static androidx.media3.exoplayer.MediaPeriodQueue.UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD; import static androidx.media3.exoplayer.RendererHolder.REPLACE_STREAMS_DISABLE_RENDERERS_COMPLETED; import static androidx.media3.exoplayer.RendererHolder.REPLACE_STREAMS_DISABLE_RENDERERS_DISABLE_OFFLOAD_SCHEDULING; import static androidx.media3.exoplayer.audio.AudioSink.OFFLOAD_MODE_DISABLED; @@ -971,8 +972,12 @@ import java.util.concurrent.atomic.AtomicBoolean; private void setRepeatModeInternal(@Player.RepeatMode int repeatMode) throws ExoPlaybackException { this.repeatMode = repeatMode; - if (!queue.updateRepeatMode(playbackInfo.timeline, repeatMode)) { + @MediaPeriodQueue.UpdatePeriodQueueResult + int result = queue.updateRepeatMode(playbackInfo.timeline, repeatMode); + if ((result & UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD) != 0) { seekToCurrentPosition(/* sendDiscontinuity= */ true); + } else if ((result & UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD) != 0) { + disableAndResetPrewarmingRenderers(); } handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); } @@ -980,8 +985,12 @@ import java.util.concurrent.atomic.AtomicBoolean; private void setShuffleModeEnabledInternal(boolean shuffleModeEnabled) throws ExoPlaybackException { this.shuffleModeEnabled = shuffleModeEnabled; - if (!queue.updateShuffleModeEnabled(playbackInfo.timeline, shuffleModeEnabled)) { + @MediaPeriodQueue.UpdatePeriodQueueResult + int result = queue.updateShuffleModeEnabled(playbackInfo.timeline, shuffleModeEnabled); + if ((result & UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD) != 0) { seekToCurrentPosition(/* sendDiscontinuity= */ true); + } else if ((result & UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD) != 0) { + disableAndResetPrewarmingRenderers(); } handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); } @@ -1950,8 +1959,10 @@ import java.util.concurrent.atomic.AtomicBoolean; if (selectionsChangedForReadPeriod) { // Update streams and rebuffer for the new selection, recreating all streams if reading ahead. MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + @MediaPeriodQueue.UpdatePeriodQueueResult int removeAfterResult = queue.removeAfter(playingPeriodHolder); - boolean recreateStreams = (removeAfterResult & REMOVE_AFTER_REMOVED_READING_PERIOD) != 0; + boolean recreateStreams = + (removeAfterResult & UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD) != 0; boolean[] streamResetFlags = new boolean[renderers.length]; long periodPositionUs = @@ -2135,9 +2146,26 @@ import java.util.concurrent.atomic.AtomicBoolean; } if (!periodPositionChanged) { // We can keep the current playing period. Update the rest of the queued periods. - if (!queue.updateQueuedPeriods( - timeline, rendererPositionUs, getMaxRendererReadPositionUs())) { + long maxRendererReadPositionUs = + queue.getReadingPeriod() == null + ? 0 + : getMaxRendererReadPositionUs(queue.getReadingPeriod()); + long maxRendererPrewarmingPositionUs = + !areRenderersPrewarming() || queue.getPrewarmingPeriod() == null + ? 0 + : getMaxRendererReadPositionUs(queue.getPrewarmingPeriod()); + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = + queue.updateQueuedPeriods( + timeline, + rendererPositionUs, + maxRendererReadPositionUs, + maxRendererPrewarmingPositionUs); + if ((updateQueuedPeriodsResult & UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD) != 0) { seekToCurrentPosition(/* sendDiscontinuity= */ false); + } else if ((updateQueuedPeriodsResult & UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD) + != 0) { + disableAndResetPrewarmingRenderers(); } } else if (!timeline.isEmpty()) { // Something changed. Seek to new start position. @@ -2239,21 +2267,20 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - private long getMaxRendererReadPositionUs() { - MediaPeriodHolder readingHolder = queue.getReadingPeriod(); - if (readingHolder == null) { + private long getMaxRendererReadPositionUs(MediaPeriodHolder periodHolder) { + if (periodHolder == null) { return 0; } - long maxReadPositionUs = readingHolder.getRendererOffset(); - if (!readingHolder.prepared) { + long maxReadPositionUs = periodHolder.getRendererOffset(); + if (!periodHolder.prepared) { return maxReadPositionUs; } for (int i = 0; i < renderers.length; i++) { - if (!renderers[i].isReadingFromPeriod(readingHolder)) { + if (!renderers[i].isReadingFromPeriod(periodHolder)) { // Ignore disabled renderers and renderers with sample streams from previous periods. continue; } - long readingPositionUs = renderers[i].getReadingPositionUs(readingHolder); + long readingPositionUs = renderers[i].getReadingPositionUs(periodHolder); if (readingPositionUs == C.TIME_END_OF_SOURCE) { return C.TIME_END_OF_SOURCE; } else { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java index 96421bb209..9bd6f28c53 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaPeriodQueue.java @@ -119,27 +119,36 @@ import java.util.List; } /** - * Sets the {@link RepeatMode} and returns whether the repeat mode change has been fully handled. - * If not, it is necessary to seek to the current playback position. + * Sets the {@link RepeatMode} and returns whether the repeat mode change change has modified the + * reading or pre-warming media periods. If it has modified the reading period then it is + * necessary to seek to the current playback position. If it has modified the pre-warming period + * then it is necessary to reset any pre-warming renderers. A value of {@code 0} is returned if it + * has neither modified the reading period nor the pre-warming period. * * @param timeline The current timeline. * @param repeatMode The new repeat mode. - * @return Whether the repeat mode change has been fully handled. + * @return {@link UpdatePeriodQueueResult} with flags denoting if the repeat mode change altered + * the current reading or pre-warming media periods. */ - public boolean updateRepeatMode(Timeline timeline, @RepeatMode int repeatMode) { + public int updateRepeatMode(Timeline timeline, @RepeatMode int repeatMode) { this.repeatMode = repeatMode; return updateForPlaybackModeChange(timeline); } /** - * Sets whether shuffling is enabled and returns whether the shuffle mode change has been fully - * handled. If not, it is necessary to seek to the current playback position. + * Sets whether shuffling is enabled and returns whether the shuffle mode change has modified the + * reading or pre-warming media periods. If it has modified the reading period, then it is + * necessary to seek to the current playback position. If it has modified the pre-warming period + * then it is necessary to reset any pre-warming renderers. A value of {@code 0} is returned if it + * has neither modified the reading period nor the pre-warming period. * * @param timeline The current timeline. * @param shuffleModeEnabled Whether shuffling mode is enabled. - * @return Whether the shuffle mode change has been fully handled. + * @return {@link UpdatePeriodQueueResult} with flags denoting if the shuffle mode change altered + * the current reading or pre-warming media periods. */ - public boolean updateShuffleModeEnabled(Timeline timeline, boolean shuffleModeEnabled) { + public @UpdatePeriodQueueResult int updateShuffleModeEnabled( + Timeline timeline, boolean shuffleModeEnabled) { this.shuffleModeEnabled = shuffleModeEnabled; return updateForPlaybackModeChange(timeline); } @@ -431,13 +440,21 @@ import java.util.List; } /** - * Removes all period holders after the given period holder. This process may also remove the - * currently reading period holder. If that is the case, the reading period holder is set to be - * the same as the playing period holder at the front of the queue. + * Removes all period holders after the given period holder. + * + *

This process may remove the currently reading period holder. If that is the case, the + * reading period holder is set to be the same as the playing period holder at the front of the + * queue. + * + *

This process may remove the currently pre-warming period holder. If that is the case, the + * pre-warming period holder is set to be the same as the reading period holder. + * + *

A value of {@code 0} is returned if the process has neither removed the reading period nor + * the pre-warming period. * * @param mediaPeriodHolder The media period holder that shall be the new end of the queue. - * @return {@link RemoveAfterResult} with flags denoting if the reading or pre-warming periods - * were removed. + * @return {@link UpdatePeriodQueueResult} with flags denoting if the reading or pre-warming + * periods were removed. */ public int removeAfter(MediaPeriodHolder mediaPeriodHolder) { checkStateNotNull(mediaPeriodHolder); @@ -451,11 +468,12 @@ import java.util.List; if (mediaPeriodHolder == reading) { reading = playing; prewarming = playing; - removedResult |= REMOVE_AFTER_REMOVED_READING_PERIOD; + removedResult |= UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD; + removedResult |= UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD; } if (mediaPeriodHolder == prewarming) { prewarming = reading; - removedResult |= REMOVE_AFTER_REMOVED_PREWARMING_PERIOD; + removedResult |= UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD; } mediaPeriodHolder.release(); length--; @@ -516,19 +534,29 @@ import java.util.List; /** * Updates media periods in the queue to take into account the latest timeline, and returns - * whether the timeline change has been fully handled. If not, it is necessary to seek to the - * current playback position. The method assumes that the first media period in the queue is still - * consistent with the new timeline. + * whether the timeline change has modified the current reading or pre-warming periods. The method + * returns {@code 0} if all changes have been handled and the reading/pre-warming periods have not + * been affected. If the reading period has been affected, then it is necessary to seek to the + * current playback position. If the pre-warming period has been affected, then it is necessary to + * reset any pre-warming renderers. The method assumes that the first media period in the queue is + * still consistent with the new timeline. * * @param timeline The new timeline. * @param rendererPositionUs The current renderer position in microseconds. * @param maxRendererReadPositionUs The maximum renderer position up to which renderers have read * the current reading media period in microseconds, or {@link C#TIME_END_OF_SOURCE} if they * have read to the end. - * @return Whether the timeline change has been handled completely. + * @param maxRendererPrewarmingPositionUs The maximum renderer position up to which renderers have + * read the current pre-warming media period in microseconds, or {@link C#TIME_END_OF_SOURCE} + * if they have read to the end. + * @return {@link UpdatePeriodQueueResult} denoting whether the timeline change has modified the + * reading or pre-warming media periods. */ - public boolean updateQueuedPeriods( - Timeline timeline, long rendererPositionUs, long maxRendererReadPositionUs) { + public @MediaPeriodQueue.UpdatePeriodQueueResult int updateQueuedPeriods( + Timeline timeline, + long rendererPositionUs, + long maxRendererReadPositionUs, + long maxRendererPrewarmingPositionUs) { // TODO: Merge this into setTimeline so that the queue gets updated as soon as the new timeline // is set, once all cases handled by ExoPlayerImplInternal.handleMediaSourceListInfoRefreshed // can be handled here. @@ -547,15 +575,10 @@ import java.util.List; } else { newPeriodInfo = getFollowingMediaPeriodInfo(timeline, previousPeriodHolder, rendererPositionUs); - if (newPeriodInfo == null) { - // We've loaded a next media period that is not in the new timeline. - int removeAfterResult = removeAfter(previousPeriodHolder); - return (removeAfterResult & REMOVE_AFTER_REMOVED_READING_PERIOD) == 0; - } - if (!canKeepMediaPeriodHolder(oldPeriodInfo, newPeriodInfo)) { - // The new media period has a different id or start position. - int removeAfterResult = removeAfter(previousPeriodHolder); - return (removeAfterResult & REMOVE_AFTER_REMOVED_READING_PERIOD) == 0; + if (newPeriodInfo == null || !canKeepMediaPeriodHolder(oldPeriodInfo, newPeriodInfo)) { + // We've loaded a next media period that is not in the new timeline + // or the new media period has a different id or start position. + return removeAfter(previousPeriodHolder); } } @@ -578,16 +601,28 @@ import java.util.List; && !periodHolder.info.isFollowedByTransitionToSameStream && (maxRendererReadPositionUs == C.TIME_END_OF_SOURCE || maxRendererReadPositionUs >= newDurationInRendererTime); - int removeAfterResult = removeAfter(periodHolder); - boolean readingPeriodRemoved = - (removeAfterResult & REMOVE_AFTER_REMOVED_READING_PERIOD) != 0; - return !readingPeriodRemoved && !isReadingAndReadBeyondNewDuration; + boolean isPrewarmingAndReadBeyondNewDuration = + periodHolder == prewarming + && (maxRendererPrewarmingPositionUs == C.TIME_END_OF_SOURCE + || maxRendererPrewarmingPositionUs >= newDurationInRendererTime); + @MediaPeriodQueue.UpdatePeriodQueueResult int removeAfterResult = removeAfter(periodHolder); + if (removeAfterResult != 0) { + return removeAfterResult; + } + int result = 0; + if (isReadingAndReadBeyondNewDuration) { + result |= UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD; + } + if (isPrewarmingAndReadBeyondNewDuration) { + result |= UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD; + } + return result; } previousPeriodHolder = periodHolder; periodHolder = periodHolder.getNext(); } - return true; + return 0; } /** @@ -846,12 +881,14 @@ import java.util.List; * handled. If not, it is necessary to seek to the current playback position. * * @param timeline The current timeline. + * @return {@link UpdatePeriodQueueResult} with flags denoting if the playback mode change altered + * the current reading or pre-warming media periods. */ - private boolean updateForPlaybackModeChange(Timeline timeline) { + private int updateForPlaybackModeChange(Timeline timeline) { // Find the last existing period holder that matches the new period order. MediaPeriodHolder lastValidPeriodHolder = playing; if (lastValidPeriodHolder == null) { - return true; + return 0; } int currentPeriodIndex = timeline.getIndexOfPeriod(lastValidPeriodHolder.uid); while (true) { @@ -876,13 +913,13 @@ import java.util.List; } // Release any period holders that don't match the new period order. + @MediaPeriodQueue.UpdatePeriodQueueResult int removeAfterResult = removeAfter(lastValidPeriodHolder); - boolean readingPeriodRemoved = (removeAfterResult & REMOVE_AFTER_REMOVED_READING_PERIOD) != 0; // Update the period info for the last holder, as it may now be the last period in the timeline. lastValidPeriodHolder.info = getUpdatedMediaPeriodInfo(timeline, lastValidPeriodHolder.info); // If renderers may have read from a period that's been removed, it is necessary to restart. - return !readingPeriodRemoved; + return removeAfterResult; } /** @@ -1256,20 +1293,27 @@ import java.util.List; } /** - * Result for {@link #removeAfter} that signifies whether the reading or pre-warming periods were - * removed during the process. + * Results for calls to {link MediaPeriodQueue} methods that may alter the reading or prewarming + * periods in the queue like {@link #updateQueuedPeriods}, {@link #removeAfter}, {@link + * #updateShuffleModeEnabled}, and {@link #updateRepeatMode}. */ @Documented @Retention(RetentionPolicy.SOURCE) @Target(TYPE_USE) @IntDef( flag = true, - value = {REMOVE_AFTER_REMOVED_READING_PERIOD, REMOVE_AFTER_REMOVED_PREWARMING_PERIOD}) - /* package */ @interface RemoveAfterResult {} + value = { + UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD, + UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD + }) + /* package */ @interface UpdatePeriodQueueResult {} - /** The call to {@link #removeAfter} removed the reading period. */ - /* package */ static final int REMOVE_AFTER_REMOVED_READING_PERIOD = 1; + /** The update altered the reading period which means that a seek is required. */ + /* package */ static final int UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD = 1; - /** The call to {@link #removeAfter} removed the pre-warming period. */ - /* package */ static final int REMOVE_AFTER_REMOVED_PREWARMING_PERIOD = 1 << 1; + /** + * The update altered the pre-warming period which means that pre-warming renderers should be + * reset. + */ + /* package */ static final int UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD = 1 << 1; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerWithPrewarmingRenderersTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerWithPrewarmingRenderersTest.java index 80d7875afe..a57c1b147e 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerWithPrewarmingRenderersTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerWithPrewarmingRenderersTest.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer; +import static androidx.media3.common.Player.REPEAT_MODE_ONE; import static androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; import static androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run; @@ -744,6 +745,309 @@ public class ExoPlayerWithPrewarmingRenderersTest { assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_DISABLED); } + @Test + public void + removeMediaItem_onPlayingPeriodWithSecondaryRendererBeforeReadingPeriodAdvanced_swapsToPrimaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock)) + .build(); + Renderer videoRenderer = player.getRenderer(/* index= */ 0); + Renderer secondaryVideoRenderer = player.getSecondaryRenderer(/* index= */ 0); + // Set a playlist that allows a new renderer to be enabled early. + player.setMediaSources( + ImmutableList.of( + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + // Use FakeBlockingMediaSource so that reading period is not advanced when pre-warming. + new FakeBlockingMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is started and primary is pre-warming. + player.play(); + run(player) + .untilBackgroundThreadCondition( + () -> secondaryVideoRenderer.getState() == Renderer.STATE_STARTED); + run(player) + .untilBackgroundThreadCondition(() -> videoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + // Remove the reading period. + player.removeMediaItem(1); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(videoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState2).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_DISABLED); + } + + @Test + public void + removeMediaItem_onPlayingPeriodWithSecondaryRendererAfterReadingPeriodAdvanced_swapsToPrimaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock)) + .build(); + Renderer videoRenderer = player.getRenderer(/* index= */ 0); + Renderer secondaryVideoRenderer = player.getSecondaryRenderer(/* index= */ 0); + // Set a playlist that allows a new renderer to be enabled early. + player.setMediaSources( + ImmutableList.of( + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is started and primary is pre-warming. + player.play(); + run(player) + .untilBackgroundThreadCondition( + () -> secondaryVideoRenderer.getState() == Renderer.STATE_STARTED); + run(player) + .untilBackgroundThreadCondition(() -> videoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + player.removeMediaItem(1); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(videoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState2).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_DISABLED); + } + + @Test + public void + removeMediaItem_onPrewarmingPeriodWithPrewarmingPrimaryRendererAfterReadingPeriodAdvanced_swapsToPrimaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock)) + .build(); + Renderer videoRenderer = player.getRenderer(/* index= */ 0); + Renderer secondaryVideoRenderer = player.getSecondaryRenderer(/* index= */ 0); + // Set a playlist that allows a new renderer to be enabled early. + player.setMediaSources( + ImmutableList.of( + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is started and primary is pre-warming. + player.play(); + run(player) + .untilBackgroundThreadCondition( + () -> secondaryVideoRenderer.getState() == Renderer.STATE_STARTED); + run(player) + .untilBackgroundThreadCondition(() -> videoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + // Remove pre-warming media item. + player.removeMediaItem(2); + run(player).untilPendingCommandsAreFullyHandled(); + run(player) + .untilBackgroundThreadCondition( + () -> secondaryVideoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(videoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState2).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_ENABLED); + } + + @Test + public void + replaceMediaItem_pastPrewarmingPeriodWithSecondaryRendererOnPlayingPeriod_doesNotDisablePrewarming() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock)) + .build(); + Renderer videoRenderer = player.getRenderer(/* index= */ 0); + Renderer secondaryVideoRenderer = player.getSecondaryRenderer(/* index= */ 0); + // Set a playlist that allows a new renderer to be enabled early. + player.setMediaSources( + ImmutableList.of( + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + // Use FakeBlockingMediaSource so that reading period is not advanced when pre-warming. + new FakeBlockingMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is started. + run(player).untilStartOfMediaItem(/* mediaItemIndex= */ 1); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + // Replace media item past pre-warming period. + player.replaceMediaItem( + 3, + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT).getMediaItem()); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(videoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState2).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_STARTED); + } + + @Test + public void + replaceMediaItem_onPrewarmingPeriodWithPrimaryRendererBeforeReadingPeriodAdvanced_disablesPrewarmingRendererOnly() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock)) + .build(); + Renderer videoRenderer = player.getRenderer(/* index= */ 0); + Renderer secondaryVideoRenderer = player.getSecondaryRenderer(/* index= */ 0); + // Set a playlist that allows a new renderer to be enabled early. + player.setMediaSources( + ImmutableList.of( + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + // Use FakeBlockingMediaSource so that reading period is not advanced when pre-warming. + new FakeBlockingMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT))); + player.prepare(); + + // Play a bit until the primary renderer is pre-warming. + run(player).untilStartOfMediaItem(/* mediaItemIndex= */ 1); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + // Replace pre-warming media item. + player.replaceMediaItem( + 2, + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT).getMediaItem()); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(videoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState2).isEqualTo(Renderer.STATE_DISABLED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_STARTED); + } + + @Test + public void + setRepeatMode_withPrewarmingBeforeReadingPeriodAdvanced_disablesPrewarmingRendererOnly() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock)) + .build(); + Renderer videoRenderer = player.getRenderer(/* index= */ 0); + Renderer secondaryVideoRenderer = player.getSecondaryRenderer(/* index= */ 0); + // Set a playlist that allows a new renderer to be enabled early. + player.setMediaSources( + ImmutableList.of( + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + // Use FakeBlockingMediaSource so that reading period is not advanced when pre-warming. + new FakeBlockingMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT), + new FakeMediaSource( + new FakeTimeline(), + ExoPlayerTestRunner.VIDEO_FORMAT, + ExoPlayerTestRunner.AUDIO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is started and primary is pre-warming. + player.play(); + run(player) + .untilBackgroundThreadCondition( + () -> secondaryVideoRenderer.getState() == Renderer.STATE_STARTED); + run(player) + .untilBackgroundThreadCondition(() -> videoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + player.setRepeatMode(REPEAT_MODE_ONE); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(videoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState2).isEqualTo(Renderer.STATE_DISABLED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_STARTED); + } + /** {@link FakeMediaSource} that prevents any reading of samples off the sample queue. */ private static final class FakeBlockingMediaSource extends FakeMediaSource { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java index fc59770ddd..d8ed07d0d6 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java @@ -16,6 +16,8 @@ package androidx.media3.exoplayer; import static androidx.media3.common.util.Util.msToUs; +import static androidx.media3.exoplayer.MediaPeriodQueue.UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD; +import static androidx.media3.exoplayer.MediaPeriodQueue.UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD; import static androidx.media3.test.utils.ExoPlayerTestRunner.AUDIO_FORMAT; import static androidx.media3.test.utils.ExoPlayerTestRunner.VIDEO_FORMAT; import static androidx.media3.test.utils.FakeMultiPeriodLiveTimeline.AD_PERIOD_DURATION_MS; @@ -861,13 +863,15 @@ public final class MediaPeriodQueueTest { setAdGroupLoaded(/* adGroupIndex= */ 0); long maxRendererReadPositionUs = MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US + FIRST_AD_START_TIME_US - 3000; - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - maxRendererReadPositionUs); + maxRendererReadPositionUs, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isTrue(); + assertThat(updateQueuedPeriodsResult).isEqualTo(0); assertThat(getQueueLength()).isEqualTo(1); assertThat(mediaPeriodQueue.getPlayingPeriod().info.endPositionUs) .isEqualTo(FIRST_AD_START_TIME_US - 2000); @@ -889,13 +893,15 @@ public final class MediaPeriodQueueTest { setAdGroupLoaded(/* adGroupIndex= */ 0); long maxRendererReadPositionUs = MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US + FIRST_AD_START_TIME_US - 1000; - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - maxRendererReadPositionUs); + maxRendererReadPositionUs, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isFalse(); + assertThat(updateQueuedPeriodsResult).isEqualTo(UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD); assertThat(getQueueLength()).isEqualTo(1); assertThat(mediaPeriodQueue.getPlayingPeriod().info.endPositionUs) .isEqualTo(FIRST_AD_START_TIME_US - 2000); @@ -926,13 +932,15 @@ public final class MediaPeriodQueueTest { setAdGroupLoaded(/* adGroupIndex= */ 0); long maxRendererReadPositionUs = MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US + FIRST_AD_START_TIME_US - 1000; - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - maxRendererReadPositionUs); + maxRendererReadPositionUs, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isTrue(); + assertThat(updateQueuedPeriodsResult).isEqualTo(0); assertThat(getQueueLength()).isEqualTo(1); assertThat(mediaPeriodQueue.getPlayingPeriod().info.endPositionUs) .isEqualTo(FIRST_AD_START_TIME_US - 2000); @@ -956,13 +964,15 @@ public final class MediaPeriodQueueTest { /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - /* maxRendererReadPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US); + /* maxRendererReadPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isTrue(); + assertThat(updateQueuedPeriodsResult).isEqualTo(0); assertThat(getQueueLength()).isEqualTo(3); } @@ -987,13 +997,18 @@ public final class MediaPeriodQueueTest { setAdGroupLoaded(/* adGroupIndex= */ 1); long maxRendererReadPositionUs = MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US + FIRST_AD_START_TIME_US; - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - maxRendererReadPositionUs); + maxRendererReadPositionUs, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isFalse(); + assertThat(updateQueuedPeriodsResult) + .isEqualTo( + UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD + | UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD); assertThat(getQueueLength()).isEqualTo(3); } @@ -1019,13 +1034,15 @@ public final class MediaPeriodQueueTest { MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US + FIRST_AD_START_TIME_US + AD_DURATION_US; - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - /* maxRendererReadPositionUs= */ readingPositionAtStartOfContentBetweenAds); + /* maxRendererReadPositionUs= */ readingPositionAtStartOfContentBetweenAds, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isTrue(); + assertThat(updateQueuedPeriodsResult).isEqualTo(0); assertThat(getQueueLength()).isEqualTo(3); } @@ -1051,13 +1068,15 @@ public final class MediaPeriodQueueTest { MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US + SECOND_AD_START_TIME_US + AD_DURATION_US; - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - /* maxRendererReadPositionUs= */ readingPositionAtEndOfContentBetweenAds); + /* maxRendererReadPositionUs= */ readingPositionAtEndOfContentBetweenAds, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isFalse(); + assertThat(updateQueuedPeriodsResult).isEqualTo(UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD); assertThat(getQueueLength()).isEqualTo(3); } @@ -1079,13 +1098,49 @@ public final class MediaPeriodQueueTest { /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); - boolean changeHandled = + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = mediaPeriodQueue.updateQueuedPeriods( playbackInfo.timeline, /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, - /* maxRendererReadPositionUs= */ C.TIME_END_OF_SOURCE); + /* maxRendererReadPositionUs= */ C.TIME_END_OF_SOURCE, + /* maxRendererPrewarmingPositionUs= */ 0); - assertThat(changeHandled).isFalse(); + assertThat(updateQueuedPeriodsResult).isEqualTo(UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD); + assertThat(getQueueLength()).isEqualTo(3); + } + + @Test + public void + updateQueuedPeriods_withDurationChangeInPrewarmingPeriodBeforeReadingPosition_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { + setupAdTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + enqueueNext(); // Content before first ad. + enqueueNext(); // First ad. + enqueueNext(); // Content between ads. + enqueueNext(); // Second ad. + advanceReading(); // Reading first ad. + mediaPeriodQueue.advancePrewarmingPeriod(); // Pre-warming content between ads. + + // Change position of second ad (= change duration of content between ads). + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); + setAdGroupLoaded(/* adGroupIndex= */ 0); + setAdGroupLoaded(/* adGroupIndex= */ 1); + long readingPositionAtEndOfContentBetweenAds = + MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US + + SECOND_AD_START_TIME_US + + AD_DURATION_US; + @MediaPeriodQueue.UpdatePeriodQueueResult + int updateQueuedPeriodsResult = + mediaPeriodQueue.updateQueuedPeriods( + playbackInfo.timeline, + /* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US, + /* maxRendererReadPositionUs= */ C.TIME_END_OF_SOURCE, + /* maxRendererPrewarmingPositionUs= */ readingPositionAtEndOfContentBetweenAds); + + assertThat(updateQueuedPeriodsResult).isEqualTo(UPDATE_PERIOD_QUEUE_ALTERED_PREWARMING_PERIOD); assertThat(getQueueLength()).isEqualTo(3); }