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(); }