diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java index 2991b77328..017028803f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java @@ -35,7 +35,12 @@ import java.util.ArrayList; import java.util.List; import org.checkerframework.dataflow.qual.SideEffectFree; -/** A renderer for metadata. */ +/** + * A renderer for metadata. + * + *

The renderer can be configured to render metadata as soon as they are available using {@link + * #MetadataRenderer(MetadataOutput, Looper, MetadataDecoderFactory, boolean)}. + */ public final class MetadataRenderer extends BaseRenderer implements Callback { private static final String TAG = "MetadataRenderer"; @@ -45,6 +50,7 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { private final MetadataOutput output; @Nullable private final Handler outputHandler; private final MetadataInputBuffer buffer; + private final boolean outputMetadataEarly; @Nullable private MetadataDecoder decoder; private boolean inputStreamEnded; @@ -54,6 +60,9 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { private long outputStreamOffsetUs; /** + * Creates an instance that uses {@link MetadataDecoderFactory#DEFAULT} to create {@link + * MetadataDecoder} instances. + * * @param output The output. * @param outputLooper The looper associated with the thread on which the output should be called. * If the output makes use of standard Android UI components, then this should normally be the @@ -66,6 +75,8 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { } /** + * Creates an instance. + * * @param output The output. * @param outputLooper The looper associated with the thread on which the output should be called. * If the output makes use of standard Android UI components, then this should normally be the @@ -76,11 +87,34 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { */ public MetadataRenderer( MetadataOutput output, @Nullable Looper outputLooper, MetadataDecoderFactory decoderFactory) { + this(output, outputLooper, decoderFactory, /* outputMetadataEarly= */ false); + } + + /** + * Creates an instance. + * + * @param output The output. + * @param outputLooper The looper associated with the thread on which the output should be called. + * If the output makes use of standard Android UI components, then this should normally be the + * looper associated with the application's main thread, which can be obtained using {@link + * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called + * directly on the player's internal rendering thread. + * @param decoderFactory A factory from which to obtain {@link MetadataDecoder} instances. + * @param outputMetadataEarly Whether the renderer outputs metadata early. When {@code true}, + * {@link #render} will output metadata as soon as they are available to the renderer, + * otherwise {@link #render} will output metadata in sync with the rendering position. + */ + public MetadataRenderer( + MetadataOutput output, + @Nullable Looper outputLooper, + MetadataDecoderFactory decoderFactory, + boolean outputMetadataEarly) { super(C.TRACK_TYPE_METADATA); this.output = Assertions.checkNotNull(output); this.outputHandler = outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this); this.decoderFactory = Assertions.checkNotNull(decoderFactory); + this.outputMetadataEarly = outputMetadataEarly; buffer = new MetadataInputBuffer(); outputStreamOffsetUs = C.TIME_UNSET; } @@ -212,7 +246,8 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { private boolean outputMetadata(long positionUs) { boolean didOutput = false; if (pendingMetadata != null - && pendingMetadata.presentationTimeUs <= getPresentationTimeUs(positionUs)) { + && (outputMetadataEarly + || pendingMetadata.presentationTimeUs <= getPresentationTimeUs(positionUs))) { invokeRenderer(pendingMetadata); pendingMetadata = null; didOutput = true; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java index 42dcaa572d..2b3b5f5be6 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java @@ -144,16 +144,87 @@ public class MetadataRendererTest { assertThat(metadata).isEmpty(); } + @Test + public void renderMetadata_withTimelyOutput() throws Exception { + EventMessage emsg = + new EventMessage( + "urn:test-scheme-id", + /* value= */ "", + /* durationMs= */ 1, + /* id= */ 0, + "Test data".getBytes(UTF_8)); + byte[] encodedEmsg = eventMessageEncoder.encode(emsg); + List metadata = new ArrayList<>(); + MetadataRenderer renderer = + new MetadataRenderer(/* output= */ metadata::add, /* outputLooper= */ null); + FakeSampleStream fakeSampleStream = + createFakeSampleStream( + ImmutableList.of( + sample(/* timeUs= */ 100_000, C.BUFFER_FLAG_KEY_FRAME, encodedEmsg), + sample(/* timeUs= */ 1_000_000, C.BUFFER_FLAG_KEY_FRAME, encodedEmsg), + END_OF_STREAM_ITEM)); + fakeSampleStream.writeData(/* startPositionUs= */ 0); + renderer.replaceStream( + new Format[] {EMSG_FORMAT}, + fakeSampleStream, + /* startPositionUs= */ 0L, + /* offsetUs= */ 0L); + + // Call render() twice, the first call is to read the format and the second call will read the + // metadata. + renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); + renderer.render(/* positionUs= */ 500_000, /* elapsedRealtimeUs= */ 0); + + assertThat(metadata).hasSize(1); + assertThat(metadata.get(0).presentationTimeUs).isEqualTo(100_000); + } + + @Test + public void renderMetadata_withEarlyOutput() throws Exception { + EventMessage emsg = + new EventMessage( + "urn:test-scheme-id", + /* value= */ "", + /* durationMs= */ 1, + /* id= */ 0, + "Test data".getBytes(UTF_8)); + byte[] encodedEmsg = eventMessageEncoder.encode(emsg); + List metadata = new ArrayList<>(); + MetadataRenderer renderer = + new MetadataRenderer( + /* output= */ metadata::add, + /* outputLooper= */ null, + MetadataDecoderFactory.DEFAULT, + /* outputMetadataEarly= */ true); + FakeSampleStream fakeSampleStream = + createFakeSampleStream( + ImmutableList.of( + sample(/* timeUs= */ 100_000, C.BUFFER_FLAG_KEY_FRAME, encodedEmsg), + sample(/* timeUs= */ 1_000_000, C.BUFFER_FLAG_KEY_FRAME, encodedEmsg), + END_OF_STREAM_ITEM)); + fakeSampleStream.writeData(/* startPositionUs= */ 0); + renderer.replaceStream( + new Format[] {EMSG_FORMAT}, + fakeSampleStream, + /* startPositionUs= */ 0L, + /* offsetUs= */ 0L); + + // Call render() twice, the first call is to read the format and the second call will read the + // metadata. + renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); + renderer.render(/* positionUs= */ 500_000, /* elapsedRealtimeUs= */ 0); + + // The renderer outputs metadata early. + assertThat(metadata).hasSize(2); + assertThat(metadata.get(0).presentationTimeUs).isEqualTo(100_000); + assertThat(metadata.get(1).presentationTimeUs).isEqualTo(1_000_000); + } + private static List runRenderer(byte[] input) throws ExoPlaybackException { List metadata = new ArrayList<>(); MetadataRenderer renderer = new MetadataRenderer(metadata::add, /* outputLooper= */ null); FakeSampleStream fakeSampleStream = - new FakeSampleStream( - new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024), - /* mediaSourceEventDispatcher= */ null, - DrmSessionManager.DRM_UNSUPPORTED, - new DrmSessionEventListener.EventDispatcher(), - EMSG_FORMAT, + createFakeSampleStream( ImmutableList.of( sample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME, input), END_OF_STREAM_ITEM)); fakeSampleStream.writeData(/* startPositionUs= */ 0); @@ -168,6 +239,17 @@ public class MetadataRendererTest { return Collections.unmodifiableList(metadata); } + private static FakeSampleStream createFakeSampleStream( + ImmutableList samples) { + return new FakeSampleStream( + new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024), + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DRM_UNSUPPORTED, + new DrmSessionEventListener.EventDispatcher(), + EMSG_FORMAT, + samples); + } + /** * Builds an ID3v2 tag containing a single 'user defined text information frame' (id='TXXX') with * {@code description} and {@code value}. diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/e2etest/DashPlaybackTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/e2etest/DashPlaybackTest.java index bca3e3362d..ffc2446a02 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/e2etest/DashPlaybackTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/e2etest/DashPlaybackTest.java @@ -25,6 +25,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.metadata.MetadataDecoderFactory; +import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.robolectric.PlaybackOutput; import com.google.android.exoplayer2.robolectric.ShadowMediaCodecConfig; import com.google.android.exoplayer2.robolectric.TestPlayerRunHelper; @@ -96,4 +100,70 @@ public final class DashPlaybackTest { DumpFileAsserts.assertOutput( applicationContext, playbackOutput, "playbackdumps/dash/emsg.dump"); } + + @Test + public void renderMetadata_withTimelyOutput() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + RenderersFactory renderersFactory = + (eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput) -> + new Renderer[] {new MetadataRenderer(metadataRendererOutput, eventHandler.getLooper())}; + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); + CapturingRenderersFactory capturingRenderersFactory = + new CapturingRenderersFactory(applicationContext); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); + + player.setMediaItem(MediaItem.fromUri("asset:///media/dash/emsg/sample.mpd")); + player.prepare(); + player.play(); + TestPlayerRunHelper.playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 500); + player.release(); + + // Ensure output contains metadata up to the playback position. + DumpFileAsserts.assertOutput( + applicationContext, playbackOutput, "playbackdumps/dash/metadata_from_timely_output.dump"); + } + + @Test + public void renderMetadata_withEarlyOutput() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + RenderersFactory renderersFactory = + (eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput) -> + new Renderer[] { + new MetadataRenderer( + metadataRendererOutput, + eventHandler.getLooper(), + MetadataDecoderFactory.DEFAULT, + /* outputMetadataEarly= */ true) + }; + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); + CapturingRenderersFactory capturingRenderersFactory = + new CapturingRenderersFactory(applicationContext); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); + + player.setMediaItem(MediaItem.fromUri("asset:///media/dash/emsg/sample.mpd")); + player.prepare(); + player.play(); + TestPlayerRunHelper.playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 500); + player.release(); + + // Ensure output contains all metadata irrespective of the playback position. + DumpFileAsserts.assertOutput( + applicationContext, playbackOutput, "playbackdumps/dash/metadata_from_early_output.dump"); + } } diff --git a/testdata/src/test/assets/playbackdumps/dash/metadata_from_early_output.dump b/testdata/src/test/assets/playbackdumps/dash/metadata_from_early_output.dump new file mode 100644 index 0000000000..e8261bd2df --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/dash/metadata_from_early_output.dump @@ -0,0 +1,10 @@ +MetadataOutput: + Metadata[0]: + presentationTimeUs = 100000 + entry[0] = EMSG: scheme=urn:mpeg:dash:event:callback:2015, id=0, durationMs=1000, value=1 + Metadata[1]: + presentationTimeUs = 100000 + entry[0] = EMSG: scheme=urn:mpeg:dash:event:callback:2015, id=1, durationMs=1000, value=1 + Metadata[2]: + presentationTimeUs = 1000000 + entry[0] = EMSG: scheme=urn:mpeg:dash:event:callback:2015, id=2, durationMs=1000, value=1 diff --git a/testdata/src/test/assets/playbackdumps/dash/metadata_from_timely_output.dump b/testdata/src/test/assets/playbackdumps/dash/metadata_from_timely_output.dump new file mode 100644 index 0000000000..ebbd67120a --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/dash/metadata_from_timely_output.dump @@ -0,0 +1,7 @@ +MetadataOutput: + Metadata[0]: + presentationTimeUs = 100000 + entry[0] = EMSG: scheme=urn:mpeg:dash:event:callback:2015, id=0, durationMs=1000, value=1 + Metadata[1]: + presentationTimeUs = 100000 + entry[0] = EMSG: scheme=urn:mpeg:dash:event:callback:2015, id=1, durationMs=1000, value=1