From d0afb96c4073b902b6d78205c96328c7c270492e Mon Sep 17 00:00:00 2001 From: samrobinson Date: Wed, 17 Jul 2024 06:19:07 -0700 Subject: [PATCH] Implement repeat mode for CompositionPlayer. ----- Context: * Each sequence is wrapped as a single MediaSource, each being played by an underlying ExoPlayer. * Repeat mode is typically implemented in Players as a seek to the next item in the playlist. ----- This CL: Repeat mode is triggered by listening for when the main input player sees a play when ready change due to the end of the media item. There is a slight delay at the end of the playback, before it repeats. Setting repeat mode on the underlying players addresses this, but means that the players will seek without waiting for the CompositionPlayer, and as such previewAudioPipeline does not get the correct signals around blocking/flushing. PreviewAudioPipeline - The seek position can validly be C.TIME_UNSET, however preview pipeline did not handle this case. CompositionPlayer getContentPosition is given (through a lambda) as a supplier to the State object, which means any comparisons between previous/new state for this value does not work. In SimpleBasePlayer, there is logic to use the positionDiscontinuityPositionUs for the position change (see getPositionInfo called from updateStateAndInformListeners), however this logic is not considered in getMediaItemTransitionReason, so a condition needed to be added for this case. ----- Tests: * Dump files clearly show the position and data is repeated. * Assertions on the reasons for transitions or position discontinuities. PiperOrigin-RevId: 653210278 --- .../CompositionPreviewActivity.java | 1 + .../media3/common/SimpleBasePlayer.java | 12 +- ...f_sample.wav-clipped__sample_rf64.wav.dump | 39 +++++ ...f_sample.wav-clipped__sample_rf64.wav.dump | 72 +++++++++ .../wav/sample.wav_repeated.dump | 66 ++++++++ ...ple.wav_then_sample_rf64.wav_repeated.dump | 96 ++++++++++++ .../media3/transformer/CompositionPlayer.java | 50 +++++- .../transformer/PreviewAudioPipeline.java | 4 + .../CompositionPlayerAudioPlaybackTest.java | 142 ++++++++++++++++++ .../transformer/CompositionPlayerTest.java | 137 +++++++++++++++++ 10 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 libraries/test_data/src/test/assets/audiosinkdumps/wav/compositionOf_sample.wav-clipped__sample_rf64.wav.dump create mode 100644 libraries/test_data/src/test/assets/audiosinkdumps/wav/repeatedCompositionOf_sample.wav-clipped__sample_rf64.wav.dump create mode 100644 libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_repeated.dump create mode 100644 libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_repeated.dump diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.java b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.java index edf1f4a1ea..0be5081996 100644 --- a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.java +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.java @@ -220,6 +220,7 @@ public final class CompositionPreviewActivity extends AppCompatActivity { Log.e(TAG, "Preview error", error); } }); + player.setRepeatMode(Player.REPEAT_MODE_ALL); player.setComposition(composition); player.prepare(); player.play(); diff --git a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java index f95d7a5537..18ff39859e 100644 --- a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java +++ b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java @@ -4008,10 +4008,14 @@ public abstract class SimpleBasePlayer extends BasePlayer { } // Only mark changes within the current item as a transition if we are repeating automatically // or via a seek to next/previous. - if (positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION - && getContentPositionMsInternal(previousState, window) - > getContentPositionMsInternal(newState, window)) { - return MEDIA_ITEM_TRANSITION_REASON_REPEAT; + if (positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION) { + if ((getContentPositionMsInternal(previousState, window) + > getContentPositionMsInternal(newState, window)) + || (newState.hasPositionDiscontinuity + && newState.discontinuityPositionMs == C.TIME_UNSET + && isRepeatingCurrentItem)) { + return MEDIA_ITEM_TRANSITION_REASON_REPEAT; + } } if (positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK && isRepeatingCurrentItem) { return MEDIA_ITEM_TRANSITION_REASON_SEEK; diff --git a/libraries/test_data/src/test/assets/audiosinkdumps/wav/compositionOf_sample.wav-clipped__sample_rf64.wav.dump b/libraries/test_data/src/test/assets/audiosinkdumps/wav/compositionOf_sample.wav-clipped__sample_rf64.wav.dump new file mode 100644 index 0000000000..d3f2a741bd --- /dev/null +++ b/libraries/test_data/src/test/assets/audiosinkdumps/wav/compositionOf_sample.wav-clipped__sample_rf64.wav.dump @@ -0,0 +1,39 @@ +AudioSink: + buffer count = 11 + config: + pcmEncoding = 2 + channelCount = 1 + sampleRate = 44100 + buffer #0: + time = 0 + data = -419876658 + buffer #1: + time = 100000 + data = -1236081112 + buffer #2: + time = 200000 + data = -1630460924 + buffer #3: + time = 300000 + data = 1478130841 + buffer #4: + time = 348616 + data = -2449 + buffer #5: + time = 348639 + data = 590036013 + buffer #6: + time = 448639 + data = -61907402 + buffer #7: + time = 500000 + data = -404977619 + buffer #8: + time = 648639 + data = -1276039913 + buffer #9: + time = 697256 + data = -1085 + buffer #10: + time = 697278 + data = 1110417718 diff --git a/libraries/test_data/src/test/assets/audiosinkdumps/wav/repeatedCompositionOf_sample.wav-clipped__sample_rf64.wav.dump b/libraries/test_data/src/test/assets/audiosinkdumps/wav/repeatedCompositionOf_sample.wav-clipped__sample_rf64.wav.dump new file mode 100644 index 0000000000..c1287f26e9 --- /dev/null +++ b/libraries/test_data/src/test/assets/audiosinkdumps/wav/repeatedCompositionOf_sample.wav-clipped__sample_rf64.wav.dump @@ -0,0 +1,72 @@ +AudioSink: + buffer count = 22 + config: + pcmEncoding = 2 + channelCount = 1 + sampleRate = 44100 + buffer #0: + time = 0 + data = -419876658 + buffer #1: + time = 100000 + data = -1236081112 + buffer #2: + time = 200000 + data = -1630460924 + buffer #3: + time = 300000 + data = 1478130841 + buffer #4: + time = 348616 + data = -2449 + buffer #5: + time = 348639 + data = 590036013 + buffer #6: + time = 448639 + data = -61907402 + buffer #7: + time = 500000 + data = -404977619 + buffer #8: + time = 648639 + data = -1276039913 + buffer #9: + time = 697256 + data = -1085 + buffer #10: + time = 697278 + data = 1110417718 + buffer #11: + time = 0 + data = -419876658 + buffer #12: + time = 100000 + data = -1236081112 + buffer #13: + time = 200000 + data = -1630460924 + buffer #14: + time = 300000 + data = 1478130841 + buffer #15: + time = 348616 + data = -2449 + buffer #16: + time = 348639 + data = 590036013 + buffer #17: + time = 448639 + data = -61907402 + buffer #18: + time = 500000 + data = -404977619 + buffer #19: + time = 648639 + data = -1276039913 + buffer #20: + time = 697256 + data = -1085 + buffer #21: + time = 697278 + data = 1110417718 diff --git a/libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_repeated.dump b/libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_repeated.dump new file mode 100644 index 0000000000..4a1a236ba2 --- /dev/null +++ b/libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_repeated.dump @@ -0,0 +1,66 @@ +AudioSink: + buffer count = 20 + config: + pcmEncoding = 2 + channelCount = 1 + sampleRate = 44100 + buffer #0: + time = 0 + data = -85819864 + buffer #1: + time = 100000 + data = 566487491 + buffer #2: + time = 200000 + data = -1256531710 + buffer #3: + time = 300000 + data = 793455796 + buffer #4: + time = 400000 + data = -268235582 + buffer #5: + time = 500000 + data = -8136122 + buffer #6: + time = 600000 + data = 1750866613 + buffer #7: + time = 700000 + data = -1100753636 + buffer #8: + time = 800000 + data = 507833230 + buffer #9: + time = 900000 + data = 1472467506 + buffer #10: + time = 0 + data = -85819864 + buffer #11: + time = 100000 + data = 566487491 + buffer #12: + time = 200000 + data = -1256531710 + buffer #13: + time = 300000 + data = 793455796 + buffer #14: + time = 400000 + data = -268235582 + buffer #15: + time = 500000 + data = -8136122 + buffer #16: + time = 600000 + data = 1750866613 + buffer #17: + time = 700000 + data = -1100753636 + buffer #18: + time = 800000 + data = 507833230 + buffer #19: + time = 900000 + data = 1472467506 diff --git a/libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_repeated.dump b/libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_repeated.dump new file mode 100644 index 0000000000..646b26acc5 --- /dev/null +++ b/libraries/test_data/src/test/assets/audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_repeated.dump @@ -0,0 +1,96 @@ +AudioSink: + buffer count = 30 + config: + pcmEncoding = 2 + channelCount = 1 + sampleRate = 44100 + buffer #0: + time = 0 + data = -85819864 + buffer #1: + time = 100000 + data = 566487491 + buffer #2: + time = 200000 + data = -1256531710 + buffer #3: + time = 300000 + data = 793455796 + buffer #4: + time = 400000 + data = -268235582 + buffer #5: + time = 500000 + data = -8136122 + buffer #6: + time = 600000 + data = 1750866613 + buffer #7: + time = 700000 + data = -1100753636 + buffer #8: + time = 800000 + data = 507833230 + buffer #9: + time = 900000 + data = 1472467506 + buffer #10: + time = 1000000 + data = 1785344804 + buffer #11: + time = 1100000 + data = 458152960 + buffer #12: + time = 1200000 + data = -2129352270 + buffer #13: + time = 1300000 + data = 1572219123 + buffer #14: + time = 1348616 + data = -2263 + buffer #15: + time = 0 + data = -85819864 + buffer #16: + time = 100000 + data = 566487491 + buffer #17: + time = 200000 + data = -1256531710 + buffer #18: + time = 300000 + data = 793455796 + buffer #19: + time = 400000 + data = -268235582 + buffer #20: + time = 500000 + data = -8136122 + buffer #21: + time = 600000 + data = 1750866613 + buffer #22: + time = 700000 + data = -1100753636 + buffer #23: + time = 800000 + data = 507833230 + buffer #24: + time = 900000 + data = 1472467506 + buffer #25: + time = 1000000 + data = 1785344804 + buffer #26: + time = 1100000 + data = 458152960 + buffer #27: + time = 1200000 + data = -2129352270 + buffer #28: + time = 1300000 + data = 1572219123 + buffer #29: + time = 1348616 + data = -2263 diff --git a/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java b/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java index 548ec6e2b1..2e78ba6c84 100644 --- a/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java +++ b/libraries/transformer/src/main/java/androidx/media3/transformer/CompositionPlayer.java @@ -80,14 +80,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * The {@link Composition} specifies how the assets should be arranged, and the audio and video * effects to apply to them. * - *

CompositionPlayer instances must be accessed from a single application thread. For the vast - * majority of cases this should be the application's main thread. The thread on which a + *

{@code CompositionPlayer} instances must be accessed from a single application thread. For the + * vast majority of cases this should be the application's main thread. The thread on which a * CompositionPlayer instance must be accessed can be explicitly specified by passing a {@link * Looper} when creating the player. If no {@link Looper} is specified, then the {@link Looper} of * the thread that the player is created on is used, or if that thread does not have a {@link * Looper}, the {@link Looper} of the application's main thread is used. In all cases the {@link * Looper} of the thread from which the player must be accessed can be queried using {@link * #getApplicationLooper()}. + * + *

This player only supports setting the {@linkplain #setRepeatMode(int) repeat mode} as + * {@linkplain Player#REPEAT_MODE_ALL all} of the {@link Composition}, or {@linkplain + * Player#REPEAT_MODE_OFF off}. */ @UnstableApi @RestrictTo(LIBRARY_GROUP) @@ -243,10 +247,12 @@ public final class CompositionPlayer extends SimpleBasePlayer COMMAND_PREPARE, COMMAND_STOP, COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, COMMAND_SEEK_BACK, COMMAND_SEEK_FORWARD, COMMAND_GET_CURRENT_MEDIA_ITEM, COMMAND_GET_TIMELINE, + COMMAND_SET_REPEAT_MODE, COMMAND_SET_VIDEO_SURFACE, COMMAND_GET_VOLUME, COMMAND_SET_VOLUME, @@ -258,11 +264,11 @@ public final class CompositionPlayer extends SimpleBasePlayer EVENT_PLAYBACK_STATE_CHANGED, EVENT_PLAY_WHEN_READY_CHANGED, EVENT_PLAYER_ERROR, - EVENT_POSITION_DISCONTINUITY + EVENT_POSITION_DISCONTINUITY, + EVENT_MEDIA_ITEM_TRANSITION, }; private static final int MAX_SUPPORTED_SEQUENCES = 2; - private static final String TAG = "CompositionPlayer"; private final Context context; @@ -283,6 +289,7 @@ public final class CompositionPlayer extends SimpleBasePlayer private long compositionDurationUs; private boolean playWhenReady; private @PlayWhenReadyChangeReason int playWhenReadyChangeReason; + private @RepeatMode int repeatMode; private float volume; private boolean renderedFirstFrame; @Nullable private Object videoOutput; @@ -290,6 +297,7 @@ public final class CompositionPlayer extends SimpleBasePlayer private @Player.State int playbackState; @Nullable private SurfaceHolder surfaceHolder; @Nullable private Surface displaySurface; + private boolean repeatingCompositionSeekInProgress; private CompositionPlayer(Builder builder) { super(checkNotNull(builder.looper), builder.clock); @@ -427,15 +435,19 @@ public final class CompositionPlayer extends SimpleBasePlayer .setPlaybackState(playbackState) .setPlayerError(playbackException) .setPlayWhenReady(playWhenReady, playWhenReadyChangeReason) + .setRepeatMode(repeatMode) .setVolume(volume) .setContentPositionMs(this::getContentPositionMs) .setContentBufferedPositionMs(this::getBufferedPositionMs) .setTotalBufferedDurationMs(this::getTotalBufferedDurationMs) .setNewlyRenderedFirstFrame(getRenderedFirstFrameAndReset()); + if (repeatingCompositionSeekInProgress) { + state.setPositionDiscontinuity(DISCONTINUITY_REASON_AUTO_TRANSITION, C.TIME_UNSET); + repeatingCompositionSeekInProgress = false; + } if (playlist != null) { // Update the playlist only after it has been set so that SimpleBasePlayer announces a - // timeline - // change with reason TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED. + // timeline change with reason TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED. state.setPlaylist(playlist); } return state.build(); @@ -467,6 +479,14 @@ public final class CompositionPlayer extends SimpleBasePlayer return Futures.immediateVoidFuture(); } + @Override + protected ListenableFuture handleSetRepeatMode(@RepeatMode int repeatMode) { + // Composition is treated as a single item, so only supports being repeated as a whole. + checkArgument(repeatMode != REPEAT_MODE_ONE); + this.repeatMode = repeatMode; + return Futures.immediateVoidFuture(); + } + @Override protected ListenableFuture handleStop() { for (int i = 0; i < players.size(); i++) { @@ -534,7 +554,8 @@ public final class CompositionPlayer extends SimpleBasePlayer } @Override - protected ListenableFuture handleSeek(int mediaItemIndex, long positionMs, int seekCommand) { + protected ListenableFuture handleSeek( + int mediaItemIndex, long positionMs, @Player.Command int seekCommand) { CompositionPlayerInternal compositionPlayerInternal = checkStateNotNull(this.compositionPlayerInternal); compositionPlayerInternal.startSeek(positionMs); @@ -643,6 +664,7 @@ public final class CompositionPlayer extends SimpleBasePlayer ExoPlayer player = playerBuilder.build(); player.addListener(new PlayerListener(i)); player.addAnalyticsListener(new EventLogger()); + player.setPauseAtEndOfMediaItems(true); setPlayerSequence(player, editedMediaItemSequence, /* shouldGenerateSilence= */ i == 0); players.add(player); if (i == 0) { @@ -790,6 +812,15 @@ public final class CompositionPlayer extends SimpleBasePlayer } } + private void repeatCompositionPlayback() { + repeatingCompositionSeekInProgress = true; + seekTo( + getCurrentMediaItemIndex(), + /* positionMs= */ C.TIME_UNSET, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + /* isRepeatingCurrentItem= */ true); + } + private ImmutableList createPlaylist() { checkNotNull(compositionDurationUs != C.TIME_UNSET); return ImmutableList.of( @@ -895,6 +926,11 @@ public final class CompositionPlayer extends SimpleBasePlayer @Override public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { playWhenReadyChangeReason = reason; + if (reason == PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM + && repeatMode != REPEAT_MODE_OFF + && playerIndex == 0) { + repeatCompositionPlayback(); + } } @Override diff --git a/libraries/transformer/src/main/java/androidx/media3/transformer/PreviewAudioPipeline.java b/libraries/transformer/src/main/java/androidx/media3/transformer/PreviewAudioPipeline.java index 44444d813a..89afedd949 100644 --- a/libraries/transformer/src/main/java/androidx/media3/transformer/PreviewAudioPipeline.java +++ b/libraries/transformer/src/main/java/androidx/media3/transformer/PreviewAudioPipeline.java @@ -18,6 +18,7 @@ package androidx.media3.transformer; import static androidx.media3.common.util.Util.sampleCountToDurationUs; +import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.audio.AudioProcessor; import androidx.media3.common.audio.AudioProcessor.AudioFormat; @@ -138,6 +139,9 @@ import java.util.Objects; * @param positionUs The seek position, in microseconds. */ public void startSeek(long positionUs) { + if (positionUs == C.TIME_UNSET) { + positionUs = 0; + } finalAudioSink.pause(); audioGraph.blockInput(); audioGraph.setPendingStartTimeUs(positionUs); diff --git a/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerAudioPlaybackTest.java b/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerAudioPlaybackTest.java index af2e080a90..ac982bb5a5 100644 --- a/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerAudioPlaybackTest.java +++ b/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerAudioPlaybackTest.java @@ -33,6 +33,7 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; @@ -195,6 +196,147 @@ public final class CompositionPlayerAudioPlaybackTest { "audiosinkdumps/wav/sample.wav_clipped_then_sample_rf64_clipped.wav.dump"); } + @Test + public void multiSequenceCompositionPlayback_outputsCorrectSamples() throws Exception { + CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink); + EditedMediaItem editedMediaItem1 = + new EditedMediaItem.Builder( + new MediaItem.Builder() + .setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW) + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(0) + .setEndPositionUs(696_000) + .build()) + .build()) + .setDurationUs(1_000_000L) + .build(); + EditedMediaItem editedMediaItem2 = + new EditedMediaItem.Builder( + MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ)) + .setDurationUs(348_000L) + .build(); + Composition composition = + new Composition.Builder( + new EditedMediaItemSequence(editedMediaItem1), + new EditedMediaItemSequence(editedMediaItem2, editedMediaItem2)) + .build(); + player.setComposition(composition); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + context, + capturingAudioSink, + "audiosinkdumps/wav/compositionOf_sample.wav-clipped__sample_rf64.wav.dump"); + } + + @Test + public void playback_withOneRepeat_outputsCorrectSamples() throws Exception { + CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + EditedMediaItem editedMediaItem = + new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW)) + .setDurationUs(1_000_000L) + .build(); + EditedMediaItemSequence sequence = new EditedMediaItemSequence(editedMediaItem); + Composition composition = new Composition.Builder(sequence).build(); + player.setComposition(composition); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + context, capturingAudioSink, "audiosinkdumps/wav/sample.wav_repeated.dump"); + } + + @Test + public void sequencePlayback_withOneRepeat_outputsCorrectSamples() throws Exception { + CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + EditedMediaItem editedMediaItem1 = + new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW)) + .setDurationUs(1_000_000L) + .build(); + EditedMediaItem editedMediaItem2 = + new EditedMediaItem.Builder( + MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ)) + .setDurationUs(348_000L) + .build(); + EditedMediaItemSequence sequence = + new EditedMediaItemSequence(editedMediaItem1, editedMediaItem2); + Composition composition = new Composition.Builder(sequence).build(); + player.setComposition(composition); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + + player.setRepeatMode(Player.REPEAT_MODE_OFF); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + context, + capturingAudioSink, + "audiosinkdumps/wav/sample.wav_then_sample_rf64.wav_repeated.dump"); + } + + // TODO - b/320014878: Enable this test. + @Ignore("Preview audio is not fed to the sink in deterministic buffers - see b/320014878.") + @Test + public void multiSequenceCompositionPlayback_withOneRepeat_outputsCorrectSamples() + throws Exception { + CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + EditedMediaItem editedMediaItem1 = + new EditedMediaItem.Builder( + new MediaItem.Builder() + .setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW) + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(0) + .setEndPositionUs(696_000) + .build()) + .build()) + .setDurationUs(1_000_000L) + .build(); + EditedMediaItem editedMediaItem2 = + new EditedMediaItem.Builder( + MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ)) + .setDurationUs(348_000L) + .build(); + Composition composition = + new Composition.Builder( + new EditedMediaItemSequence(editedMediaItem1), + new EditedMediaItemSequence(editedMediaItem2, editedMediaItem2)) + .build(); + player.setComposition(composition); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + + player.setRepeatMode(Player.REPEAT_MODE_OFF); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + context, + capturingAudioSink, + "audiosinkdumps/wav/repeatedCompositionOf_sample.wav-clipped__sample_rf64.wav.dump"); + } + @Test public void seekTo_outputsCorrectSamples() throws Exception { CompositionPlayer player = createCompositionPlayer(context, capturingAudioSink); diff --git a/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerTest.java b/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerTest.java index 0585e11abd..0a4dd10c10 100644 --- a/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerTest.java +++ b/libraries/transformer/src/test/java/androidx/media3/transformer/CompositionPlayerTest.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import android.content.Context; @@ -232,11 +233,13 @@ public class CompositionPlayerTest { Player.COMMAND_PREPARE, Player.COMMAND_STOP, Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, Player.COMMAND_SEEK_BACK, Player.COMMAND_SEEK_FORWARD, Player.COMMAND_GET_CURRENT_MEDIA_ITEM, Player.COMMAND_GET_TIMELINE, Player.COMMAND_SET_VIDEO_SURFACE, + Player.COMMAND_SET_REPEAT_MODE, Player.COMMAND_GET_VOLUME, Player.COMMAND_SET_VOLUME, Player.COMMAND_RELEASE); @@ -541,6 +544,140 @@ public class CompositionPlayerTest { .isEqualTo(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); } + @Test + public void playSequence_withRepeatModeOff_doesNotReportRepeatMediaItemTransition() + throws Exception { + CompositionPlayer player = buildCompositionPlayer(); + Player.Listener mockListener = mock(Player.Listener.class); + player.addListener(mockListener); + player.setComposition(buildComposition()); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + + verify(mockListener, never()) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT)); + verify(mockListener, never()) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + } + + @Test + public void playSequence_withRepeatModeAll_reportsRepeatReasonForMediaItemTransition() + throws Exception { + CompositionPlayer player = buildCompositionPlayer(); + Player.Listener mockListener = mock(Player.Listener.class); + player.addListener(mockListener); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + player.setComposition(buildComposition()); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + + verify(mockListener, times(3)) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT)); + verify(mockListener, never()) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + } + + @Test + public void playComposition_withRepeatModeOff_doesNotReportRepeatMediaItemTransition() + throws Exception { + CompositionPlayer player = buildCompositionPlayer(); + Player.Listener mockListener = mock(Player.Listener.class); + player.addListener(mockListener); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + EditedMediaItem editedMediaItem1 = + new EditedMediaItem.Builder( + new MediaItem.Builder() + .setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW) + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(0) + .setEndPositionUs(696_000) + .build()) + .build()) + .setDurationUs(1_000_000L) + .build(); + EditedMediaItem editedMediaItem2 = + new EditedMediaItem.Builder( + MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ)) + .setDurationUs(348_000L) + .build(); + Composition composition = + new Composition.Builder( + new EditedMediaItemSequence(editedMediaItem1), + new EditedMediaItemSequence(editedMediaItem2, editedMediaItem2)) + .build(); + + player.setComposition(composition); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + + verify(mockListener, never()) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT)); + verify(mockListener, never()) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + } + + @Test + public void playComposition_withRepeatModeAll_reportsRepeatReasonForMediaItemTransition() + throws Exception { + CompositionPlayer player = buildCompositionPlayer(); + Player.Listener mockListener = mock(Player.Listener.class); + player.addListener(mockListener); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + EditedMediaItem editedMediaItem1 = + new EditedMediaItem.Builder( + new MediaItem.Builder() + .setUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW) + .setClippingConfiguration( + new MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(0) + .setEndPositionUs(696_000) + .build()) + .build()) + .setDurationUs(1_000_000L) + .build(); + EditedMediaItem editedMediaItem2 = + new EditedMediaItem.Builder( + MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_STEREO_48000KHZ)) + .setDurationUs(348_000L) + .build(); + Composition composition = + new Composition.Builder( + new EditedMediaItemSequence(editedMediaItem1), + new EditedMediaItemSequence(editedMediaItem2, editedMediaItem2)) + .build(); + player.setComposition(composition); + player.prepare(); + player.play(); + + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + TestPlayerRunHelper.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_AUTO_TRANSITION); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + + verify(mockListener, times(3)) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT)); + verify(mockListener, never()) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SEEK)); + } + @Test public void seekPastDuration_ends() throws Exception { CompositionPlayer player = buildCompositionPlayer();