From 6689fee2b2bf17f3faca71d179b38c3e486dff32 Mon Sep 17 00:00:00 2001 From: michaelkatz Date: Mon, 9 Dec 2024 14:00:52 -0800 Subject: [PATCH] Implement error handling support for pre-warming renderers PiperOrigin-RevId: 704408379 --- .../exoplayer/ExoPlayerImplInternal.java | 39 +- .../media3/exoplayer/RendererHolder.java | 6 + .../ExoPlayerWithPrewarmingRenderersTest.java | 465 ++++++++++++++++++ 3 files changed, 506 insertions(+), 4 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 b661bf52bc..427ac88465 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -235,6 +235,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private PreloadConfiguration preloadConfiguration; private Timeline lastPreloadPoolInvalidationTimeline; private long prewarmingMediaPeriodDiscontinuity = C.TIME_UNSET; + private boolean isPrewarmingDisabledUntilNextTransition; public ExoPlayerImplInternal( Renderer[] renderers, @@ -298,7 +299,7 @@ import java.util.concurrent.atomic.AtomicBoolean; rendererCapabilities[i].setListener(rendererCapabilitiesListener); } if (secondaryRenderers[i] != null) { - secondaryRenderers[i].init(/* index= */ i, playerId, clock); + secondaryRenderers[i].init(/* index= */ i + renderers.length, playerId, clock); hasSecondaryRenderers = true; } this.renderers[i] = new RendererHolder(renderers[i], secondaryRenderers[i], /* index= */ i); @@ -678,7 +679,13 @@ import java.util.concurrent.atomic.AtomicBoolean; if (readingPeriod != null) { // We can assume that all renderer errors happen in the context of the reading period. See // [internal: b/150584930#comment4] for exceptions that aren't covered by this assumption. - e = e.copyWithMediaPeriodId(readingPeriod.info.id); + e = + e.copyWithMediaPeriodId( + (renderers[e.rendererIndex % renderers.length].isRendererPrewarming( + e.rendererIndex) + && readingPeriod.getNext() != null) + ? readingPeriod.getNext().info.id + : readingPeriod.info.id); } } if (e.isRecoverable @@ -699,6 +706,25 @@ import java.util.concurrent.atomic.AtomicBoolean; // recovered or the player stopped before any other message is handled. handler.sendMessageAtFrontOfQueue( handler.obtainMessage(MSG_ATTEMPT_RENDERER_ERROR_RECOVERY, e)); + } else if (e.type == ExoPlaybackException.TYPE_RENDERER + && renderers[e.rendererIndex % renderers.length].isRendererPrewarming( + /* id= */ e.rendererIndex)) { + // TODO(b/380273486): Investigate recovery for pre-warming renderer errors + isPrewarmingDisabledUntilNextTransition = true; + disableAndResetPrewarmingRenderers(); + // Remove periods from the queue starting at the pre-warming period. + MediaPeriodHolder prewarmingPeriod = queue.getPrewarmingPeriod(); + MediaPeriodHolder periodToRemoveAfter = queue.getPlayingPeriod(); + if (queue.getPlayingPeriod() != prewarmingPeriod) { + while (periodToRemoveAfter != null && periodToRemoveAfter.getNext() != prewarmingPeriod) { + periodToRemoveAfter = periodToRemoveAfter.getNext(); + } + } + queue.removeAfter(periodToRemoveAfter); + if (playbackInfo.playbackState != Player.STATE_ENDED) { + maybeContinueLoading(); + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } } else { if (pendingRecoverableRendererError != null) { pendingRecoverableRendererError.addSuppressed(e); @@ -2336,7 +2362,10 @@ import java.util.concurrent.atomic.AtomicBoolean; private void maybeUpdatePrewarmingPeriod() throws ExoPlaybackException { // TODO: Add limit as to not enable waiting renderer too early - if (pendingPauseAtEndOfPeriod || !hasSecondaryRenderers || areRenderersPrewarming()) { + if (pendingPauseAtEndOfPeriod + || !hasSecondaryRenderers + || isPrewarmingDisabledUntilNextTransition + || areRenderersPrewarming()) { return; } @Nullable MediaPeriodHolder prewarmingPeriodHolder = queue.getPrewarmingPeriod(); @@ -2446,7 +2475,8 @@ import java.util.concurrent.atomic.AtomicBoolean; // The new period starts with a discontinuity, so unless a pre-warming renderer is handling // the discontinuity, the renderers will play out all data, then // be disabled and re-enabled when they start playing the next period. - boolean arePrewarmingRenderersHandlingDiscontinuity = hasSecondaryRenderers; + boolean arePrewarmingRenderersHandlingDiscontinuity = + hasSecondaryRenderers && !isPrewarmingDisabledUntilNextTransition; if (arePrewarmingRenderersHandlingDiscontinuity) { for (int i = 0; i < renderers.length; i++) { if (!newTrackSelectorResult.isRendererEnabled(i)) { @@ -2580,6 +2610,7 @@ import java.util.concurrent.atomic.AtomicBoolean; // If we advance more than one period at a time, notify listeners after each update. maybeNotifyPlaybackInfoChanged(); } + isPrewarmingDisabledUntilNextTransition = false; MediaPeriodHolder newPlayingPeriodHolder = checkNotNull(queue.advancePlayingPeriod()); boolean isCancelledSSAIAdTransition = playbackInfo.periodId.periodUid.equals(newPlayingPeriodHolder.info.id.periodUid) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java index b52f831c3f..19f60ca103 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java @@ -80,6 +80,12 @@ import java.util.Objects; return isPrimaryRendererPrewarming() || isSecondaryRendererPrewarming(); } + public boolean isRendererPrewarming(int id) { + boolean isPrewarmingPrimaryRenderer = isPrimaryRendererPrewarming() && id == index; + boolean isPrewarmingSecondaryRenderer = isSecondaryRendererPrewarming() && id != index; + return isPrewarmingPrimaryRenderer || isPrewarmingSecondaryRenderer; + } + private boolean isPrimaryRendererPrewarming() { return prewarmingState == RENDERER_PREWARMING_STATE_PREWARMING_PRIMARY || prewarmingState == RENDERER_PREWARMING_STATE_TRANSITIONING_TO_PRIMARY; 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 a57c1b147e..38790a7838 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerWithPrewarmingRenderersTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerWithPrewarmingRenderersTest.java @@ -19,7 +19,13 @@ 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; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilError; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import android.content.Context; import android.os.Handler; @@ -27,6 +33,7 @@ import android.util.Pair; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; +import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; import androidx.media3.common.Timeline; import androidx.media3.common.util.Clock; @@ -36,6 +43,7 @@ import androidx.media3.decoder.DecoderInputBuffer; import androidx.media3.exoplayer.audio.AudioRendererEventListener; import androidx.media3.exoplayer.drm.DrmSessionEventListener; import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.mediacodec.MediaCodecRenderer; import androidx.media3.exoplayer.metadata.MetadataOutput; import androidx.media3.exoplayer.source.MediaPeriod; import androidx.media3.exoplayer.source.MediaSourceEventListener; @@ -60,6 +68,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Rule; @@ -1048,6 +1057,417 @@ public class ExoPlayerWithPrewarmingRenderersTest { assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_STARTED); } + @Test + public void + play_errorByPrewarmingSecondaryRendererBeforeAdvancingReadingPeriod_doesNotResetPrimaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + Player.Listener listener = mock(Player.Listener.class); + AtomicBoolean attemptedRenderWithSecondaryRenderer = new AtomicBoolean(false); + AtomicBoolean shouldSecondaryRendererThrow = new AtomicBoolean(true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRendererThatThrows( + fakeClock, attemptedRenderWithSecondaryRenderer, shouldSecondaryRendererThrow)) + .build(); + player.addListener(listener); + 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( + // 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))); + player.prepare(); + + // Play a bit until the second renderer is enabled and throws errors. + run(player).untilState(Player.STATE_READY); + player.play(); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState = videoRenderer.getState(); + @Renderer.State int secondaryVideoState = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(attemptedRenderWithSecondaryRenderer.get()).isTrue(); + verify(listener, never()).onPositionDiscontinuity(any(), any(), anyInt()); + assertThat(videoState).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState).isEqualTo(Renderer.STATE_DISABLED); + } + + @Test + public void + play_errorByPrewarmingSecondaryRendererAfterAdvancingReadingPeriod_doesNotResetPrimaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + Player.Listener listener = mock(Player.Listener.class); + AtomicBoolean attemptedRenderWithSecondaryRenderer = new AtomicBoolean(false); + AtomicBoolean shouldSecondaryRendererThrow = new AtomicBoolean(true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRendererThatThrows( + fakeClock, attemptedRenderWithSecondaryRenderer, shouldSecondaryRendererThrow)) + .build(); + player.addListener(listener); + 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))); + player.prepare(); + + // Play a bit until the second renderer is enabled and throws error. + run(player).untilState(Player.STATE_READY); + player.play(); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + assertThat(attemptedRenderWithSecondaryRenderer.get()).isTrue(); + + attemptedRenderWithSecondaryRenderer.set(false); + // Play a bit so that primary renderer is enabled on second media item. + run(player).untilStartOfMediaItem(/* mediaItemIndex= */ 1); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + verify(listener).onPositionDiscontinuity(any(), any(), anyInt()); + // Secondary renderer will not be used subsequently after failure. + assertThat(attemptedRenderWithSecondaryRenderer.get()).isFalse(); + assertThat(videoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_DISABLED); + assertThat(videoState2).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_DISABLED); + } + + @Test + public void play_errorByPrewarmingSecondaryRenderer_primaryRendererIsUsedOnSubsequentMediaItem() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + Player.Listener listener = mock(Player.Listener.class); + AtomicBoolean attemptedRenderWithSecondaryRenderer = new AtomicBoolean(false); + AtomicBoolean shouldSecondaryRendererThrow = new AtomicBoolean(true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRendererThatThrows( + fakeClock, attemptedRenderWithSecondaryRenderer, shouldSecondaryRendererThrow)) + .build(); + player.addListener(listener); + 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), + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is enabled and throws error. + run(player).untilState(Player.STATE_READY); + player.play(); + run(player).untilBackgroundThreadCondition(attemptedRenderWithSecondaryRenderer::get); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + assertThat(attemptedRenderWithSecondaryRenderer.get()).isTrue(); + + shouldSecondaryRendererThrow.set(false); + run(player).untilStartOfMediaItem(/* mediaItemIndex= */ 1); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + verify(listener).onPositionDiscontinuity(any(), any(), anyInt()); + assertThat(videoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_DISABLED); + assertThat(videoState2).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_DISABLED); + } + + @Test + public void + play_withSecondaryRendererNonRecoverableErrorForMultipleMediaItems_primaryRendererIsUsed() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + Player.Listener listener = mock(Player.Listener.class); + AtomicBoolean attemptedRenderWithSecondaryRenderer = new AtomicBoolean(false); + AtomicBoolean shouldSecondaryRendererThrow = new AtomicBoolean(true); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRendererThatThrows( + fakeClock, attemptedRenderWithSecondaryRenderer, shouldSecondaryRendererThrow)) + .build(); + player.addListener(listener); + 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), + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is started. + run(player).untilState(Player.STATE_READY); + player.play(); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + assertThat(attemptedRenderWithSecondaryRenderer.get()).isTrue(); + run(player).untilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ 500); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + shouldSecondaryRendererThrow.set(false); + run(player).untilPosition(/* mediaItemIndex= */ 1, /* positionMs= */ 500); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState3 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState3 = secondaryVideoRenderer.getState(); + player.release(); + + verify(listener).onPositionDiscontinuity(any(), any(), anyInt()); + assertThat(videoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_DISABLED); + assertThat(videoState2).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_DISABLED); + assertThat(videoState3).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState3).isEqualTo(Renderer.STATE_ENABLED); + } + + @Test + public void play_errorWithPrimaryRendererDuringPrewarming_doesNotResetSecondaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + Player.Listener listener = mock(Player.Listener.class); + AtomicBoolean shouldPrimaryRendererThrow = new AtomicBoolean(false); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock) { + @Override + public Renderer[] createRenderers( + Handler eventHandler, + VideoRendererEventListener videoRendererEventListener, + AudioRendererEventListener audioRendererEventListener, + TextOutput textRendererOutput, + MetadataOutput metadataRendererOutput) { + HandlerWrapper clockAwareHandler = + clock.createHandler(eventHandler.getLooper(), /* callback= */ null); + return new Renderer[] { + new FakeVideoRenderer(clockAwareHandler, videoRendererEventListener) { + @Override + public void render(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException { + if (!shouldPrimaryRendererThrow.get()) { + super.render(positionUs, elapsedRealtimeUs); + } else { + throw createRendererException( + new MediaCodecRenderer.DecoderInitializationException( + new Format.Builder().build(), + new IllegalArgumentException(), + false, + 0), + this.getFormatHolder().format, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED); + } + } + }, + new FakeAudioRenderer(clockAwareHandler, audioRendererEventListener) + }; + } + }) + .build(); + player.addListener(listener); + 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 FakeBlockingMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT), + new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT))); + player.prepare(); + + // Play a bit until the second renderer is pre-warming. + player.play(); + run(player) + .untilBackgroundThreadCondition( + () -> secondaryVideoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + run(player) + .untilBackgroundThreadCondition(() -> videoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + shouldPrimaryRendererThrow.set(true); + run(player) + .untilBackgroundThreadCondition(() -> videoRenderer.getState() == Renderer.STATE_DISABLED); + @Renderer.State int videoState3 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState3 = secondaryVideoRenderer.getState(); + player.release(); + + assertThat(videoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(videoState2).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState3).isEqualTo(Renderer.STATE_DISABLED); + assertThat(secondaryVideoState3).isEqualTo(Renderer.STATE_STARTED); + } + + @Test + public void + play_errorWithPrimaryWhilePrewarmingSecondaryPriorToAdvancingReadingPeriod_restartingPlaybackWillUseSecondaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + Player.Listener listener = mock(Player.Listener.class); + AtomicBoolean shouldPrimaryRendererThrow = new AtomicBoolean(false); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRenderer(fakeClock) { + @Override + public Renderer[] createRenderers( + Handler eventHandler, + VideoRendererEventListener videoRendererEventListener, + AudioRendererEventListener audioRendererEventListener, + TextOutput textRendererOutput, + MetadataOutput metadataRendererOutput) { + HandlerWrapper clockAwareHandler = + clock.createHandler(eventHandler.getLooper(), /* callback= */ null); + return new Renderer[] { + new FakeVideoRenderer(clockAwareHandler, videoRendererEventListener) { + @Override + public void render(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException { + if (!shouldPrimaryRendererThrow.get()) { + super.render(positionUs, elapsedRealtimeUs); + } else { + throw createRendererException( + new MediaCodecRenderer.DecoderInitializationException( + new Format.Builder().build(), + new IllegalArgumentException(), + false, + 0), + this.getFormatHolder().format, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED); + } + } + }, + new FakeAudioRenderer(clockAwareHandler, audioRendererEventListener) + }; + } + }) + .build(); + player.addListener(listener); + 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( + // 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))); + player.prepare(); + + // Play a bit until the second renderer is enabled. + run(player).untilState(Player.STATE_READY); + player.play(); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState1 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState1 = secondaryVideoRenderer.getState(); + // Force primary renderer to error, killing playback. + shouldPrimaryRendererThrow.set(true); + run(player).untilPlayerError(); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + // Restart playback with primary renderer functioning properly. + shouldPrimaryRendererThrow.set(false); + player.prepare(); + player.play(); + run(player).untilPosition(/* mediaItemIndex= */ 0, /* positionMs= */ 500); + run(player).untilPendingCommandsAreFullyHandled(); + @Renderer.State int videoState3 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState3 = secondaryVideoRenderer.getState(); + player.release(); + + verify(listener, never()).onPositionDiscontinuity(any(), any(), anyInt()); + assertThat(videoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(videoState2).isEqualTo(Renderer.STATE_DISABLED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_DISABLED); + assertThat(videoState3).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState3).isEqualTo(Renderer.STATE_ENABLED); + } + + @Test + public void + play_errorWithSecondaryWhilePrewarmingPrimaryPriorToAdvancingReadingPeriod_restartingPlaybackWillPrewarmSecondaryRenderer() + throws Exception { + Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + Player.Listener listener = mock(Player.Listener.class); + AtomicBoolean attemptedRenderWithSecondaryRenderer = new AtomicBoolean(false); + AtomicBoolean shouldSecondaryRendererThrow = new AtomicBoolean(false); + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(fakeClock) + .setRenderersFactory( + new FakeRenderersFactorySupportingSecondaryVideoRendererThatThrows( + fakeClock, attemptedRenderWithSecondaryRenderer, shouldSecondaryRendererThrow)) + .build(); + player.addListener(listener); + 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))); + player.prepare(); + + // Play a bit until on second media item and 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(); + // Force secondary renderer to error, killing playback. + shouldSecondaryRendererThrow.set(true); + runUntilError(player); + // Restart playback with secondary renderer functioning properly. + shouldSecondaryRendererThrow.set(false); + player.prepare(); + // Play until secondary renderer is pre-warming. + run(player) + .untilBackgroundThreadCondition( + () -> secondaryVideoRenderer.getState() == Renderer.STATE_ENABLED); + @Renderer.State int videoState2 = videoRenderer.getState(); + @Renderer.State int secondaryVideoState2 = secondaryVideoRenderer.getState(); + player.release(); + + verify(listener).onPositionDiscontinuity(any(), any(), anyInt()); + assertThat(videoState1).isEqualTo(Renderer.STATE_ENABLED); + assertThat(secondaryVideoState1).isEqualTo(Renderer.STATE_STARTED); + assertThat(videoState2).isEqualTo(Renderer.STATE_STARTED); + assertThat(secondaryVideoState2).isEqualTo(Renderer.STATE_ENABLED); + } + /** {@link FakeMediaSource} that prevents any reading of samples off the sample queue. */ private static final class FakeBlockingMediaSource extends FakeMediaSource { @@ -1145,4 +1565,49 @@ public class ExoPlayerWithPrewarmingRenderersTest { return null; } } + + private static final class FakeRenderersFactorySupportingSecondaryVideoRendererThatThrows + extends FakeRenderersFactorySupportingSecondaryVideoRenderer { + private final AtomicBoolean attemptedRenderWithSecondaryRenderer; + private final AtomicBoolean shouldSecondaryRendererThrow; + + public FakeRenderersFactorySupportingSecondaryVideoRendererThatThrows( + Clock clock, + AtomicBoolean attemptedRenderWithSecondaryRenderer, + AtomicBoolean shouldSecondaryRendererThrow) { + super(clock); + this.attemptedRenderWithSecondaryRenderer = attemptedRenderWithSecondaryRenderer; + this.shouldSecondaryRendererThrow = shouldSecondaryRendererThrow; + } + + @Override + public Renderer createSecondaryRenderer( + Renderer renderer, + Handler eventHandler, + VideoRendererEventListener videoRendererEventListener, + AudioRendererEventListener audioRendererEventListener, + TextOutput textRendererOutput, + MetadataOutput metadataRendererOutput) { + if (renderer instanceof FakeVideoRenderer) { + return new FakeVideoRenderer( + clock.createHandler(eventHandler.getLooper(), /* callback= */ null), + videoRendererEventListener) { + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + attemptedRenderWithSecondaryRenderer.set(true); + if (!shouldSecondaryRendererThrow.get()) { + super.render(positionUs, elapsedRealtimeUs); + } else { + throw createRendererException( + new MediaCodecRenderer.DecoderInitializationException( + new Format.Builder().build(), new IllegalArgumentException(), false, 0), + this.getFormatHolder().format, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED); + } + } + }; + } + return null; + } + } }