diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java index 93f4687e31..0d0a7a23d7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -82,13 +82,19 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { } @Override - public final void enable(RendererConfiguration configuration, Format[] formats, - SampleStream stream, long positionUs, boolean joining, long offsetUs) + public final void enable( + RendererConfiguration configuration, + Format[] formats, + SampleStream stream, + long positionUs, + boolean joining, + boolean mayRenderStartOfStream, + long offsetUs) throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); this.configuration = configuration; state = STATE_ENABLED; - onEnabled(joining); + onEnabled(joining, mayRenderStartOfStream); replaceStream(formats, stream, offsetUs); onPositionReset(positionUs, joining); } @@ -193,27 +199,30 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { /** * Called when the renderer is enabled. - *

- * The default implementation is a no-op. + * + *

The default implementation is a no-op. * * @param joining Whether this renderer is being enabled to join an ongoing playback. + * @param mayRenderStartOfStream Whether this renderer is allowed to render the start of the + * stream even if the state is not {@link #STATE_STARTED} yet. * @throws ExoPlaybackException If an error occurs. */ - protected void onEnabled(boolean joining) throws ExoPlaybackException { + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { // Do nothing. } /** * Called when the renderer's stream has changed. This occurs when the renderer is enabled after - * {@link #onEnabled(boolean)} has been called, and also when the stream has been replaced whilst - * the renderer is enabled or started. - *

- * The default implementation is a no-op. + * {@link #onEnabled(boolean, boolean)} has been called, and also when the stream has been + * replaced whilst the renderer is enabled or started. + * + *

The default implementation is a no-op. * * @param formats The enabled formats. - * @param offsetUs The offset that will be added to the timestamps of buffers read via - * {@link #readSource(FormatHolder, DecoderInputBuffer, boolean)} so that decoder input - * buffers have monotonically increasing timestamps. + * @param offsetUs The offset that will be added to the timestamps of buffers read via {@link + * #readSource(FormatHolder, DecoderInputBuffer, boolean)} so that decoder input buffers have + * monotonically increasing timestamps. * @throws ExoPlaybackException If an error occurs. */ protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 6742ec2d44..196dd35e96 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -2002,6 +2002,7 @@ import java.util.concurrent.atomic.AtomicBoolean; playingPeriodHolder.sampleStreams[rendererIndex], rendererPositionUs, joining, + /* mayRenderStartOfStream= */ true, playingPeriodHolder.getRendererOffset()); mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java index 2889787db0..47ed8cec6a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java @@ -60,24 +60,15 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities return state; } - /** - * Replaces the {@link SampleStream} that will be associated with this renderer. - *

- * This method may be called when the renderer is in the following states: - * {@link #STATE_DISABLED}. - * - * @param configuration The renderer configuration. - * @param formats The enabled formats. Should be empty. - * @param stream The {@link SampleStream} from which the renderer should consume. - * @param positionUs The player's current position. - * @param joining Whether this renderer is being enabled to join an ongoing playback. - * @param offsetUs The offset that should be subtracted from {@code positionUs} - * to get the playback position with respect to the media. - * @throws ExoPlaybackException If an error occurs. - */ @Override - public final void enable(RendererConfiguration configuration, Format[] formats, - SampleStream stream, long positionUs, boolean joining, long offsetUs) + public final void enable( + RendererConfiguration configuration, + Format[] formats, + SampleStream stream, + long positionUs, + boolean joining, + boolean mayRenderStartOfStream, + long offsetUs) throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); this.configuration = configuration; @@ -94,18 +85,6 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities onStarted(); } - /** - * Replaces the {@link SampleStream} that will be associated with this renderer. - *

- * This method may be called when the renderer is in the following states: - * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. - * - * @param formats The enabled formats. Should be empty. - * @param stream The {@link SampleStream} to be associated with this renderer. - * @param offsetUs The offset that should be subtracted from {@code positionUs} in - * {@link #render(long, long)} to get the playback position with respect to the media. - * @throws ExoPlaybackException If an error occurs. - */ @Override public final void replaceStream(Format[] formats, SampleStream stream, long offsetUs) throws ExoPlaybackException { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index 9d6dbb5e9e..a23cd58e80 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -222,21 +222,30 @@ public interface Renderer extends PlayerMessage.Target { /** * Enables the renderer to consume from the specified {@link SampleStream}. - *

- * This method may be called when the renderer is in the following states: - * {@link #STATE_DISABLED}. + * + *

This method may be called when the renderer is in the following states: {@link + * #STATE_DISABLED}. * * @param configuration The renderer configuration. * @param formats The enabled formats. * @param stream The {@link SampleStream} from which the renderer should consume. * @param positionUs The player's current position. * @param joining Whether this renderer is being enabled to join an ongoing playback. - * @param offsetUs The offset to be added to timestamps of buffers read from {@code stream} - * before they are rendered. + * @param mayRenderStartOfStream Whether this renderer is allowed to render the start of the + * stream even if the state is not {@link #STATE_STARTED} yet. + * @param offsetUs The offset to be added to timestamps of buffers read from {@code stream} before + * they are rendered. * @throws ExoPlaybackException If an error occurs. */ - void enable(RendererConfiguration configuration, Format[] formats, SampleStream stream, - long positionUs, boolean joining, long offsetUs) throws ExoPlaybackException; + void enable( + RendererConfiguration configuration, + Format[] formats, + SampleStream stream, + long positionUs, + boolean joining, + boolean mayRenderStartOfStream, + long offsetUs) + throws ExoPlaybackException; /** * Starts the renderer, meaning that calls to {@link #render(long, long)} will cause media to be @@ -341,21 +350,32 @@ public interface Renderer extends PlayerMessage.Target { /** * Incrementally renders the {@link SampleStream}. - *

- * If the renderer is in the {@link #STATE_ENABLED} state then each call to this method will do - * work toward being ready to render the {@link SampleStream} when the renderer is started. It may - * also render the very start of the media, for example the first frame of a video stream. If the + * + *

If the renderer is in the {@link #STATE_ENABLED} state then each call to this method will do + * work toward being ready to render the {@link SampleStream} when the renderer is started. If the * renderer is in the {@link #STATE_STARTED} state then calls to this method will render the * {@link SampleStream} in sync with the specified media positions. - *

- * This method should return quickly, and should not block if the renderer is unable to make - * useful progress. - *

- * This method may be called when the renderer is in the following states: - * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. * - * @param positionUs The current media time in microseconds, measured at the start of the - * current iteration of the rendering loop. + *

The renderer may also render the very start of the media at the current position (e.g. the + * first frame of a video stream) while still in the {@link #STATE_ENABLED} state. It's not + * allowed to do that in the following two cases: + * + *

    + *
  1. The initial start of the media after calling {@link #enable(RendererConfiguration, + * Format[], SampleStream, long, boolean, boolean, long)} with {@code + * mayRenderStartOfStream} set to {@code false}. + *
  2. The start of a new stream after calling {@link #replaceStream(Format[], SampleStream, + * long)}. + *
+ * + *

This method should return quickly, and should not block if the renderer is unable to make + * useful progress. + * + *

This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @param positionUs The current media time in microseconds, measured at the start of the current + * iteration of the rendering loop. * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, * measured at the start of the current iteration of the rendering loop. * @throws ExoPlaybackException If an error occurs. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index c4b4bc0af9..8c496e4ffd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -491,8 +491,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { - super.onEnabled(joining); + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + super.onEnabled(joining, mayRenderStartOfStream); eventDispatcher.enabled(decoderCounters); int tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java index 41e2401f98..7da3ac9c6e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java @@ -495,7 +495,8 @@ public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements } @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { decoderCounters = new DecoderCounters(); eventDispatcher.enabled(decoderCounters); int tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index dc8118267b..81edeb952d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -679,7 +679,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { decoderCounters = new DecoderCounters(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 00d64e18d0..a423237a6b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -129,7 +129,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private Surface surface; private Surface dummySurface; @VideoScalingMode private int scalingMode; - private boolean renderedFirstFrame; + private boolean renderedFirstFrameAfterReset; + private boolean mayRenderFirstFrameAfterEnableIfNotStarted; + private boolean renderedFirstFrameAfterEnable; private long initialPositionUs; private long joiningDeadlineMs; private long droppedFrameAccumulationStartTimeMs; @@ -360,8 +362,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { - super.onEnabled(joining); + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + super.onEnabled(joining, mayRenderStartOfStream); int oldTunnelingAudioSessionId = tunnelingAudioSessionId; tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; tunneling = tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET; @@ -370,6 +373,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } eventDispatcher.enabled(decoderCounters); frameReleaseTimeHelper.enable(); + mayRenderFirstFrameAfterEnableIfNotStarted = mayRenderStartOfStream; + renderedFirstFrameAfterEnable = false; } @Override @@ -387,8 +392,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @Override public boolean isReady() { - if (super.isReady() && (renderedFirstFrame || (dummySurface != null && surface == dummySurface) - || getCodec() == null || tunneling)) { + if (super.isReady() + && (renderedFirstFrameAfterReset + || (dummySurface != null && surface == dummySurface) + || getCodec() == null + || tunneling)) { // Ready. If we were joining then we've now joined, so clear the joining deadline. joiningDeadlineMs = C.TIME_UNSET; return true; @@ -729,11 +737,15 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000; long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderTimeUs; boolean isStarted = getState() == STATE_STARTED; + boolean shouldRenderFirstFrame = + !renderedFirstFrameAfterEnable + ? (isStarted || mayRenderFirstFrameAfterEnableIfNotStarted) + : !renderedFirstFrameAfterReset; // Don't force output until we joined and the position reached the current stream. boolean forceRenderOutputBuffer = joiningDeadlineMs == C.TIME_UNSET && positionUs >= outputStreamOffsetUs - && (!renderedFirstFrame + && (shouldRenderFirstFrame || (isStarted && shouldForceRenderOutputBuffer(earlyUs, elapsedSinceLastRenderUs))); if (forceRenderOutputBuffer) { long releaseTimeNs = System.nanoTime(); @@ -1056,7 +1068,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } private void clearRenderedFirstFrame() { - renderedFirstFrame = false; + renderedFirstFrameAfterReset = false; // The first frame notification is triggered by renderOutputBuffer or renderOutputBufferV21 for // non-tunneled playback, onQueueInputBuffer for tunneled playback prior to API level 23, and // OnFrameRenderedListenerV23.onFrameRenderedListener for tunneled playback on API level 23 and @@ -1071,14 +1083,15 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } /* package */ void maybeNotifyRenderedFirstFrame() { - if (!renderedFirstFrame) { - renderedFirstFrame = true; + renderedFirstFrameAfterEnable = true; + if (!renderedFirstFrameAfterReset) { + renderedFirstFrameAfterReset = true; eventDispatcher.renderedFirstFrame(surface); } } private void maybeRenotifyRenderedFirstFrame() { - if (renderedFirstFrame) { + if (renderedFirstFrameAfterReset) { eventDispatcher.renderedFirstFrame(surface); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/SimpleDecoderVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/SimpleDecoderVideoRenderer.java index bb77367d37..afc53b90fe 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/SimpleDecoderVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/SimpleDecoderVideoRenderer.java @@ -92,7 +92,9 @@ public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { @ReinitializationState private int decoderReinitializationState; private boolean decoderReceivedBuffers; - private boolean renderedFirstFrame; + private boolean renderedFirstFrameAfterReset; + private boolean mayRenderFirstFrameAfterEnableIfNotStarted; + private boolean renderedFirstFrameAfterEnable; private long initialPositionUs; private long joiningDeadlineMs; private boolean waitingForKeys; @@ -195,7 +197,7 @@ public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { } if (inputFormat != null && (isSourceReady() || outputBuffer != null) - && (renderedFirstFrame || !hasOutput())) { + && (renderedFirstFrameAfterReset || !hasOutput())) { // Ready. If we were joining then we've now joined, so clear the joining deadline. joiningDeadlineMs = C.TIME_UNSET; return true; @@ -215,9 +217,12 @@ public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { // Protected methods. @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { decoderCounters = new DecoderCounters(); eventDispatcher.enabled(decoderCounters); + mayRenderFirstFrameAfterEnableIfNotStarted = mayRenderStartOfStream; + renderedFirstFrameAfterEnable = false; } @Override @@ -267,6 +272,9 @@ public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { @Override protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + // TODO: This shouldn't just update the output stream offset as long as there are still buffers + // of the previous stream in the decoder. It should also make sure to render the first frame of + // the next stream if the playback position reached the new stream and the renderer is started. outputStreamOffsetUs = offsetUs; super.onStreamChanged(formats, offsetUs); } @@ -787,10 +795,15 @@ public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { } long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000; + long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderTimeUs; boolean isStarted = getState() == STATE_STARTED; - if (!renderedFirstFrame - || (isStarted - && shouldForceRenderOutputBuffer(earlyUs, elapsedRealtimeNowUs - lastRenderTimeUs))) { + boolean shouldRenderFirstFrame = + !renderedFirstFrameAfterEnable + ? (isStarted || mayRenderFirstFrameAfterEnableIfNotStarted) + : !renderedFirstFrameAfterReset; + // TODO: We shouldn't force render while we are joining an ongoing playback. + if (shouldRenderFirstFrame + || (isStarted && shouldForceRenderOutputBuffer(earlyUs, elapsedSinceLastRenderUs))) { renderOutputBuffer(outputBuffer, presentationTimeUs, outputFormat); return true; } @@ -799,6 +812,7 @@ public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { return false; } + // TODO: Treat dropped buffers as skipped while we are joining an ongoing playback. if (shouldDropBuffersToKeyframe(earlyUs, elapsedRealtimeUs) && maybeDropBuffersToKeyframe(positionUs)) { return false; @@ -862,18 +876,19 @@ public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { } private void clearRenderedFirstFrame() { - renderedFirstFrame = false; + renderedFirstFrameAfterReset = false; } private void maybeNotifyRenderedFirstFrame() { - if (!renderedFirstFrame) { - renderedFirstFrame = true; + renderedFirstFrameAfterEnable = true; + if (!renderedFirstFrameAfterReset) { + renderedFirstFrameAfterReset = true; eventDispatcher.renderedFirstFrame(surface); } } private void maybeRenotifyRenderedFirstFrame() { - if (renderedFirstFrame) { + if (renderedFirstFrameAfterReset) { eventDispatcher.renderedFirstFrame(surface); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 4b3fcbe018..44fe13e9b8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -5606,7 +5606,8 @@ public final class ExoPlayerTest { FakeRenderer audioRenderer = new FakeRenderer(Builder.AUDIO_FORMAT) { @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { // Fail when enabling the renderer. This will happen during the period transition. throw createRendererException(new IllegalStateException(), Builder.AUDIO_FORMAT); } @@ -5672,7 +5673,8 @@ public final class ExoPlayerTest { FakeRenderer audioRenderer = new FakeRenderer(Builder.AUDIO_FORMAT) { @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { // Fail when enabling the renderer. This will happen during the playlist update. throw createRendererException(new IllegalStateException(), Builder.AUDIO_FORMAT); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 33fea63e44..fe3bc63d17 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -241,8 +241,8 @@ public final class AnalyticsCollectorTest { period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period1); - assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0, period1); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)).containsExactly(period1); listener.assertNoMoreEvents(); } @@ -444,8 +444,10 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period1Seq2); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) .containsExactly(period0, period1Seq2, period1Seq2); - assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0, period0); - assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0, period0); + assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) + .containsExactly(period0, period1Seq1, period0, period1Seq2); + assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) + .containsExactly(period0, period1Seq1, period0, period1Seq2); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(period0, period1Seq2, period1Seq2); listener.assertNoMoreEvents(); @@ -672,9 +674,9 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(window0Period1Seq0); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(window0Period1Seq0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(window0Period1Seq0, period1Seq0); + .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(window0Period1Seq0, period1Seq0); + .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(window0Period1Seq0); listener.assertNoMoreEvents(); @@ -964,8 +966,22 @@ public final class AnalyticsCollectorTest { contentAfterPostroll); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll); - assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(prerollAd); - assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(prerollAd); + assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) + .containsExactly( + prerollAd, + contentAfterPreroll, + midrollAd, + contentAfterMidroll, + postrollAd, + contentAfterPostroll); + assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) + .containsExactly( + prerollAd, + contentAfterPreroll, + midrollAd, + contentAfterMidroll, + postrollAd, + contentAfterPostroll); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll); listener.assertNoMoreEvents(); @@ -1082,9 +1098,9 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(contentBeforeMidroll); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(contentAfterMidroll); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(contentBeforeMidroll, midrollAd); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(contentBeforeMidroll, midrollAd); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(contentAfterMidroll); listener.assertNoMoreEvents(); @@ -1194,7 +1210,10 @@ public final class AnalyticsCollectorTest { private final VideoRendererEventListener.EventDispatcher eventDispatcher; private final DecoderCounters decoderCounters; private Format format; - private boolean renderedFirstFrame; + private long streamOffsetUs; + private boolean renderedFirstFrameAfterReset; + private boolean mayRenderFirstFrameAfterStreamChangeIfNotStarted; + private boolean renderedFirstFrameAfterStreamChange; public FakeVideoRenderer(Handler handler, VideoRendererEventListener eventListener) { super(ExoPlayerTestRunner.Builder.VIDEO_FORMAT); @@ -1203,10 +1222,23 @@ public final class AnalyticsCollectorTest { } @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { - super.onEnabled(joining); + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + super.onEnabled(joining, mayRenderStartOfStream); eventDispatcher.enabled(decoderCounters); - renderedFirstFrame = false; + mayRenderFirstFrameAfterStreamChangeIfNotStarted = mayRenderStartOfStream; + renderedFirstFrameAfterStreamChange = false; + } + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + super.onStreamChanged(formats, offsetUs); + streamOffsetUs = offsetUs; + if (renderedFirstFrameAfterReset) { + renderedFirstFrameAfterReset = false; + renderedFirstFrameAfterStreamChange = false; + mayRenderFirstFrameAfterStreamChangeIfNotStarted = false; + } } @Override @@ -1226,7 +1258,7 @@ public final class AnalyticsCollectorTest { @Override protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { super.onPositionReset(positionUs, joining); - renderedFirstFrame = false; + renderedFirstFrameAfterReset = false; } @Override @@ -1242,11 +1274,18 @@ public final class AnalyticsCollectorTest { @Override protected boolean shouldProcessBuffer(long bufferTimeUs, long playbackPositionUs) { boolean shouldProcess = super.shouldProcessBuffer(bufferTimeUs, playbackPositionUs); - if (shouldProcess && !renderedFirstFrame) { + boolean shouldRenderFirstFrame = + !renderedFirstFrameAfterStreamChange + ? (getState() == Renderer.STATE_STARTED + || mayRenderFirstFrameAfterStreamChangeIfNotStarted) + : !renderedFirstFrameAfterReset; + shouldProcess |= shouldRenderFirstFrame && playbackPositionUs >= streamOffsetUs; + if (shouldProcess && !renderedFirstFrameAfterReset) { eventDispatcher.videoSizeChanged( format.width, format.height, format.rotationDegrees, format.pixelWidthHeightRatio); eventDispatcher.renderedFirstFrame(/* surface= */ null); - renderedFirstFrame = true; + renderedFirstFrameAfterReset = true; + renderedFirstFrameAfterStreamChange = true; } return shouldProcess; } @@ -1265,8 +1304,9 @@ public final class AnalyticsCollectorTest { } @Override - protected void onEnabled(boolean joining) throws ExoPlaybackException { - super.onEnabled(joining); + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + super.onEnabled(joining, mayRenderStartOfStream); eventDispatcher.enabled(decoderCounters); notifiedAudioSessionId = false; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRendererTest.java index dc8787aac7..387dd7e0ea 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/SimpleDecoderAudioRendererTest.java @@ -97,9 +97,10 @@ public class SimpleDecoderAudioRendererTest { RendererConfiguration.DEFAULT, new Format[] {FORMAT}, new FakeSampleStream(FORMAT, /* eventDispatcher= */ null, /* shouldOutputSample= */ false), - 0, - false, - 0); + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs= */ 0); audioRenderer.setCurrentStreamFinal(); when(mockAudioSink.isEnded()).thenReturn(true); while (!audioRenderer.isEnded()) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/SimpleDecoderVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/SimpleDecoderVideoRendererTest.java new file mode 100644 index 0000000000..04dcb020d0 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/SimpleDecoderVideoRendererTest.java @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.video; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.graphics.SurfaceTexture; +import android.os.Handler; +import android.os.SystemClock; +import android.view.Surface; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.decoder.SimpleDecoder; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.testutil.FakeSampleStream; +import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; +import com.google.android.exoplayer2.util.MimeTypes; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit test for {@link SimpleDecoderVideoRenderer}. */ +@RunWith(AndroidJUnit4.class) +public final class SimpleDecoderVideoRendererTest { + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private static final Format BASIC_MP4_1080 = + Format.createVideoSampleFormat( + /* id= */ null, + /* sampleMimeType= */ MimeTypes.VIDEO_MP4, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + /* width= */ 1920, + /* height= */ 1080, + /* frameRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* rotationDegrees= */ 0, + /* pixelWidthHeightRatio= */ 1f, + /* drmInitData= */ null); + + private SimpleDecoderVideoRenderer renderer; + @Mock private VideoRendererEventListener eventListener; + + @Before + public void setUp() { + renderer = + new SimpleDecoderVideoRenderer( + /* allowedJoiningTimeMs= */ 0, + new Handler(), + eventListener, + /* maxDroppedFramesToNotify= */ -1) { + @C.VideoOutputMode private int outputMode; + + @Override + @Capabilities + public int supportsFormat(Format format) { + return RendererCapabilities.create(FORMAT_HANDLED); + } + + @Override + protected void setDecoderOutputMode(@C.VideoOutputMode int outputMode) { + this.outputMode = outputMode; + } + + @Override + protected void renderOutputBufferToSurface( + VideoDecoderOutputBuffer outputBuffer, Surface surface) { + // Do nothing. + } + + @Override + protected SimpleDecoder< + VideoDecoderInputBuffer, + ? extends VideoDecoderOutputBuffer, + ? extends VideoDecoderException> + createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) { + return new SimpleDecoder< + VideoDecoderInputBuffer, VideoDecoderOutputBuffer, VideoDecoderException>( + new VideoDecoderInputBuffer[10], new VideoDecoderOutputBuffer[10]) { + @Override + protected VideoDecoderInputBuffer createInputBuffer() { + return new VideoDecoderInputBuffer(); + } + + @Override + protected VideoDecoderOutputBuffer createOutputBuffer() { + return new VideoDecoderOutputBuffer(this::releaseOutputBuffer); + } + + @Override + protected VideoDecoderException createUnexpectedDecodeException(Throwable error) { + return new VideoDecoderException("error", error); + } + + @Nullable + @Override + protected VideoDecoderException decode( + VideoDecoderInputBuffer inputBuffer, + VideoDecoderOutputBuffer outputBuffer, + boolean reset) { + outputBuffer.init(inputBuffer.timeUs, outputMode, /* supplementalData= */ null); + return null; + } + + @Override + public String getName() { + return "TestDecoder"; + } + }; + } + }; + renderer.setOutputSurface(new Surface(new SurfaceTexture(/* texName= */ 0))); + } + + @Test + public void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ BASIC_MP4_1080, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + + renderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {BASIC_MP4_1080}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + for (int i = 0; i < 10; i++) { + renderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + + verify(eventListener).onRenderedFirstFrame(any()); + } + + @Test + public void enable_withoutMayRenderStartOfStream_doesNotRenderFirstFrameBeforeStart() + throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ BASIC_MP4_1080, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + + renderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {BASIC_MP4_1080}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + for (int i = 0; i < 10; i++) { + renderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + + verify(eventListener, never()).onRenderedFirstFrame(any()); + } + + @Test + public void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* format= */ BASIC_MP4_1080, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + + renderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {BASIC_MP4_1080}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* offsetUs */ 0); + renderer.start(); + for (int i = 0; i < 10; i++) { + renderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + + verify(eventListener).onRenderedFirstFrame(any()); + } + + // TODO: First frame of replaced stream are not yet reported. + @Ignore + @Test + public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exception { + FakeSampleStream fakeSampleStream1 = + new FakeSampleStream( + /* format= */ BASIC_MP4_1080, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + FakeSampleStream fakeSampleStream2 = + new FakeSampleStream( + /* format= */ BASIC_MP4_1080, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + renderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {BASIC_MP4_1080}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + renderer.start(); + + boolean replacedStream = false; + for (int i = 0; i < 200; i += 10) { + renderer.render(/* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); + if (!replacedStream && renderer.hasReadStreamToEnd()) { + renderer.replaceStream( + new Format[] {BASIC_MP4_1080}, fakeSampleStream2, /* offsetUs= */ 100); + replacedStream = true; + } + } + + verify(eventListener, times(2)).onRenderedFirstFrame(any()); + } + + @Test + public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { + FakeSampleStream fakeSampleStream1 = + new FakeSampleStream( + /* format= */ BASIC_MP4_1080, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + FakeSampleStream fakeSampleStream2 = + new FakeSampleStream( + /* format= */ BASIC_MP4_1080, + /* eventDispatcher= */ null, + /* firstSampleTimeUs= */ 0, + /* timeUsIncrement= */ 50, + new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM); + renderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {BASIC_MP4_1080}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* offsetUs */ 0); + + boolean replacedStream = false; + for (int i = 0; i < 200; i += 10) { + renderer.render(/* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); + if (!replacedStream && renderer.hasReadStreamToEnd()) { + renderer.replaceStream( + new Format[] {BASIC_MP4_1080}, fakeSampleStream2, /* offsetUs= */ 100); + replacedStream = true; + } + } + + verify(eventListener).onRenderedFirstFrame(any()); + } +}