From d8498f3ecb2a6450f832cdafa5933b4cd2696548 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 13 Jul 2023 10:02:47 +0100 Subject: [PATCH] Set signal on renderer once it's allowed to render start of stream When a renderer is pre-enabled (while another playback is still ongoing), we pass mayRenderStartOfStream=false to Renderer.enable. This ensures we don't show any first frames while the previous media is still playing. Currently, we never tell the renderer when we actually stop playing the previous media so that it could render the start of the stream, because we allow this as soon as the renderer is in STATE_STARTED and we assume that we have to be in STATE_STARTED to make this stream transition. While this assumption is true, there are also cases where we can't start the renderers because they are not ready yet and the video renderer can't become ready because it didn't render its first frame. This effectively blocks playback forever. The most direct way of solving this, is to tell the renderer that playback has transitioned and that it is now allowed to render the start of the stream. This means it can never get blocked as described above. PiperOrigin-RevId: 547727347 --- .../exoplayer/ExoPlayerImplInternal.java | 10 ++ .../androidx/media3/exoplayer/Renderer.java | 9 ++ .../exoplayer/video/DecoderVideoRenderer.java | 5 + .../video/MediaCodecVideoRenderer.java | 5 + .../media3/exoplayer/ExoPlayerTest.java | 116 +++++++++++++++++- .../robolectric/ShadowMediaCodecConfig.java | 9 ++ 6 files changed, 153 insertions(+), 1 deletion(-) 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 f3035b0683..4c0c840b4c 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -2265,10 +2265,20 @@ import java.util.concurrent.atomic.AtomicBoolean; Player.DISCONTINUITY_REASON_AUTO_TRANSITION); resetPendingPauseAtEndOfPeriod(); updatePlaybackPositions(); + allowRenderersToRenderStartOfStreams(); advancedPlayingPeriod = true; } } + private void allowRenderersToRenderStartOfStreams() { + TrackSelectorResult playingTracks = queue.getPlayingPeriod().getTrackSelectorResult(); + for (int i = 0; i < renderers.length; i++) { + if (playingTracks.isRendererEnabled(i)) { + renderers[i].enableMayRenderStartOfStream(); + } + } + } + private void resetPendingPauseAtEndOfPeriod() { @Nullable MediaPeriodHolder playingPeriod = queue.getPlayingPeriod(); pendingPauseAtEndOfPeriod = diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java index ff994b1f8a..17311ca7d1 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java @@ -462,6 +462,15 @@ public interface Renderer extends PlayerMessage.Target { default void setPlaybackSpeed(float currentPlaybackSpeed, float targetPlaybackSpeed) throws ExoPlaybackException {} + /** + * Enables this renderer to render the start of the stream even if the state is not {@link + * #STATE_STARTED} yet. + * + *

This is used to update the value of {@code mayRenderStartOfStream} passed to {@link + * #enable}. + */ + default void enableMayRenderStartOfStream() {} + /** * Incrementally renders the {@link SampleStream}. * diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/DecoderVideoRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/DecoderVideoRenderer.java index 91a90f4502..f263d19d20 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/DecoderVideoRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/DecoderVideoRenderer.java @@ -280,6 +280,11 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { renderedFirstFrameAfterEnable = false; } + @Override + public void enableMayRenderStartOfStream() { + mayRenderFirstFrameAfterEnableIfNotStarted = true; + } + @Override protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { inputStreamEnded = false; diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java index d65e4da7cf..9bf9a88cb9 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java @@ -605,6 +605,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { renderedFirstFrameAfterEnable = false; } + @Override + public void enableMayRenderStartOfStream() { + mayRenderFirstFrameAfterEnableIfNotStarted = true; + } + @Override protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { super.onPositionReset(positionUs, joining); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index 3b11df3ca0..bbd205b523 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -62,6 +62,7 @@ import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.playUnt import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.playUntilStartOfMediaItem; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilError; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPlayWhenReady; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPlaybackState; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPositionDiscontinuity; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilSleepingForOffload; @@ -129,6 +130,7 @@ import androidx.media3.common.util.HandlerWrapper; import androidx.media3.common.util.SystemClock; import androidx.media3.common.util.Util; import androidx.media3.datasource.TransferListener; +import androidx.media3.decoder.DecoderInputBuffer; import androidx.media3.exoplayer.analytics.AnalyticsListener; import androidx.media3.exoplayer.audio.AudioRendererEventListener; import androidx.media3.exoplayer.drm.DrmSessionEventListener; @@ -178,6 +180,7 @@ import androidx.media3.test.utils.FakeTrackSelection; import androidx.media3.test.utils.FakeTrackSelector; import androidx.media3.test.utils.FakeVideoRenderer; import androidx.media3.test.utils.TestExoPlayerBuilder; +import androidx.media3.test.utils.robolectric.ShadowMediaCodecConfig; import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -201,6 +204,7 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; @@ -227,9 +231,15 @@ public final class ExoPlayerTest { */ private static final int TIMEOUT_MS = 10_000; + private static final String SAMPLE_URI = "asset://android_asset/media/mp4/sample.mp4"; + private Context context; private Timeline placeholderTimeline; + @Rule + public ShadowMediaCodecConfig mediaCodecConfig = + ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); @@ -12542,7 +12552,7 @@ public final class ExoPlayerTest { MediaItem mediaItem = new MediaItem.Builder() .setMediaId("id") - .setUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")) + .setUri(Uri.parse(SAMPLE_URI)) .setMediaMetadata(mediaMetadata) .build(); player.setMediaItem(mediaItem); @@ -13729,6 +13739,110 @@ public final class ExoPlayerTest { player.release(); } + @Test + public void pauseAtEndOfMediaItem_withSecondStreamDelayed_playsSuccessfully() throws Exception { + // Set allowed video joining time to zero so that the renderer is not automatically considered + // ready when we re-enable it at the transition. + ExoPlayer player = + new ExoPlayer.Builder(context) + .setRenderersFactory( + new DefaultRenderersFactory(context).setAllowedVideoJoiningTimeMs(0)) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setPauseAtEndOfMediaItems(true); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 0)); + player.setVideoSurface(surface); + player.addMediaItem(MediaItem.fromUri(SAMPLE_URI)); + Timeline timeline = new FakeTimeline(); + AtomicBoolean allowStreamRead = new AtomicBoolean(); + MediaSource delayedStreamSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + @Override + protected MediaPeriod createMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + long startPositionUs = + -timeline + .getPeriodByUid(id.periodUid, new Timeline.Period()) + .getPositionInWindowUs(); + // Add enough samples to the source so that the decoder can't decode everything at once. + return new FakeMediaPeriod( + trackGroupArray, + allocator, + (format, mediaPerioid) -> + ImmutableList.of( + oneByteSample(startPositionUs, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(startPositionUs + 10_000), + oneByteSample(startPositionUs + 20_000), + oneByteSample(startPositionUs + 30_000), + oneByteSample(startPositionUs + 40_000), + oneByteSample(startPositionUs + 50_000), + oneByteSample(startPositionUs + 60_000), + oneByteSample(startPositionUs + 70_000), + oneByteSample(startPositionUs + 80_000), + oneByteSample(startPositionUs + 90_000), + oneByteSample(startPositionUs + 100_000), + END_OF_STREAM_ITEM), + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false) { + @Override + protected FakeSampleStream createSampleStream( + Allocator allocator, + @Nullable MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + Format initialFormat, + List fakeSampleStreamItems) { + return new FakeSampleStream( + allocator, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + initialFormat, + fakeSampleStreamItems) { + @Override + public int readData( + FormatHolder formatHolder, + DecoderInputBuffer buffer, + @ReadFlags int readFlags) { + return allowStreamRead.get() + ? super.readData(formatHolder, buffer, readFlags) + : C.RESULT_NOTHING_READ; + } + }; + } + }; + } + }; + player.addMediaSource(delayedStreamSource); + Player.Listener listener = mock(Player.Listener.class); + player.addListener(listener); + + player.play(); + player.prepare(); + runUntilPlayWhenReady(player, /* expectedPlayWhenReady= */ false); + player.play(); + runUntilPlaybackState(player, Player.STATE_BUFFERING); + allowStreamRead.set(true); + runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + surface.release(); + + // Verify that playback is paused at the end and buffered at the start of each item. + verify(listener, times(2)) + .onPlayWhenReadyChanged( + /* playWhenReady= */ eq(false), + eq(Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM)); + verify(listener, times(2)).onPlaybackStateChanged(Player.STATE_BUFFERING); + } + // Internal methods. private void addWatchAsSystemFeature() { diff --git a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/ShadowMediaCodecConfig.java b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/ShadowMediaCodecConfig.java index fdb57338bd..c875fd9ff5 100644 --- a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/ShadowMediaCodecConfig.java +++ b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/ShadowMediaCodecConfig.java @@ -25,6 +25,7 @@ import android.media.MediaFormat; import androidx.media3.common.C; import androidx.media3.common.MimeTypes; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import androidx.media3.exoplayer.mediacodec.MediaCodecUtil; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -76,6 +77,10 @@ public final class ShadowMediaCodecConfig extends ExternalResource { @Override protected void before() throws Throwable { + if (Util.SDK_INT <= 19) { + // Codec config not supported with Robolectric on API <= 19. Skip rule set up step. + return; + } configureCodecs(supportedMimeTypes); } @@ -83,6 +88,10 @@ public final class ShadowMediaCodecConfig extends ExternalResource { protected void after() { supportedMimeTypes.clear(); MediaCodecUtil.clearDecoderInfoCache(); + if (Util.SDK_INT <= 19) { + // Codec config not supported with Robolectric on API <= 19. Skip rule tear down step. + return; + } ShadowMediaCodecList.reset(); ShadowMediaCodec.clearCodecs(); }