diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle index 77f504de422f..789d0949572a 100644 --- a/packages/video_player/video_player_android/android/build.gradle +++ b/packages/video_player/video_player_android/android/build.gradle @@ -57,6 +57,7 @@ android { testImplementation 'androidx.test:core:1.3.0' testImplementation 'org.mockito:mockito-inline:5.0.0' testImplementation 'org.robolectric:robolectric:4.10.3' + testImplementation "androidx.media3:media3-test-utils:1.3.1" } testOptions { diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java new file mode 100644 index 000000000000..3d1b3d850d29 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java @@ -0,0 +1,28 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; + +final class LocalVideoAsset extends VideoAsset { + LocalVideoAsset(@NonNull String assetUrl) { + super(assetUrl); + } + + @NonNull + @Override + MediaItem getMediaItem() { + return new MediaItem.Builder().setUri(assetUrl).build(); + } + + @Override + MediaSource.Factory getMediaSourceFactory(Context context) { + return new DefaultMediaSourceFactory(context); + } +} diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java new file mode 100644 index 000000000000..75c3c42d96f9 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java @@ -0,0 +1,97 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; +import java.util.Map; + +final class RemoteVideoAsset extends VideoAsset { + private static final String DEFAULT_USER_AGENT = "ExoPlayer"; + private static final String HEADER_USER_AGENT = "User-Agent"; + + @NonNull private final StreamingFormat streamingFormat; + @NonNull private final Map httpHeaders; + + RemoteVideoAsset( + @Nullable String assetUrl, + @NonNull StreamingFormat streamingFormat, + @NonNull Map httpHeaders) { + super(assetUrl); + this.streamingFormat = streamingFormat; + this.httpHeaders = httpHeaders; + } + + @NonNull + @Override + MediaItem getMediaItem() { + MediaItem.Builder builder = new MediaItem.Builder().setUri(assetUrl); + String mimeType = null; + switch (streamingFormat) { + case SMOOTH: + mimeType = MimeTypes.APPLICATION_SS; + break; + case DYNAMIC_ADAPTIVE: + mimeType = MimeTypes.APPLICATION_MPD; + break; + case HTTP_LIVE: + mimeType = MimeTypes.APPLICATION_M3U8; + break; + } + if (mimeType != null) { + builder.setMimeType(mimeType); + } + return builder.build(); + } + + @Override + MediaSource.Factory getMediaSourceFactory(Context context) { + return getMediaSourceFactory(context, new DefaultHttpDataSource.Factory()); + } + + /** + * Returns a configured media source factory, starting at the provided factory. + * + *

This method is provided for ease of testing without making real HTTP calls. + * + * @param context application context. + * @param initialFactory initial factory, to be configured. + * @return configured factory, or {@code null} if not needed for this asset type. + */ + @VisibleForTesting + MediaSource.Factory getMediaSourceFactory( + Context context, DefaultHttpDataSource.Factory initialFactory) { + String userAgent = DEFAULT_USER_AGENT; + if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) { + userAgent = httpHeaders.get(HEADER_USER_AGENT); + } + unstableUpdateDataSourceFactory(initialFactory, httpHeaders, userAgent); + DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, initialFactory); + return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSoruceFactory); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @OptIn(markerClass = UnstableApi.class) + private static void unstableUpdateDataSourceFactory( + @NonNull DefaultHttpDataSource.Factory factory, + @NonNull Map httpHeaders, + @Nullable String userAgent) { + factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true); + if (!httpHeaders.isEmpty()) { + factory.setDefaultRequestProperties(httpHeaders); + } + } +} diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java new file mode 100644 index 000000000000..2b83437c6fc6 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java @@ -0,0 +1,83 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.MediaSource; +import java.util.HashMap; +import java.util.Map; + +/** A video to be played by {@link VideoPlayer}. */ +abstract class VideoAsset { + /** + * Returns an asset from a local {@code asset:///} URL, i.e. an on-device asset. + * + * @param assetUrl local asset, beginning in {@code asset:///}. + * @return the asset. + */ + @NonNull + static VideoAsset fromAssetUrl(@NonNull String assetUrl) { + if (!assetUrl.startsWith("asset:///")) { + throw new IllegalArgumentException("assetUrl must start with 'asset:///'"); + } + return new LocalVideoAsset(assetUrl); + } + + /** + * Returns an asset from a remote URL. + * + * @param remoteUrl remote asset, i.e. typically beginning with {@code https://} or similar. + * @param streamingFormat which streaming format, provided as a hint if able. + * @param httpHeaders HTTP headers to set for a request. + * @return the asset. + */ + @NonNull + static VideoAsset fromRemoteUrl( + @Nullable String remoteUrl, + @NonNull StreamingFormat streamingFormat, + @NonNull Map httpHeaders) { + return new RemoteVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders)); + } + + @Nullable protected final String assetUrl; + + protected VideoAsset(@Nullable String assetUrl) { + this.assetUrl = assetUrl; + } + + /** + * Returns the configured media item to be played. + * + * @return media item. + */ + @NonNull + abstract MediaItem getMediaItem(); + + /** + * Returns the configured media source factory, if needed for this asset type. + * + * @param context application context. + * @return configured factory, or {@code null} if not needed for this asset type. + */ + abstract MediaSource.Factory getMediaSourceFactory(Context context); + + /** Streaming formats that can be provided to the video player as a hint. */ + enum StreamingFormat { + /** Default, if the format is either not known or not another valid format. */ + UNKNOWN, + + /** Smooth Streaming. */ + SMOOTH, + + /** MPEG-DASH (Dynamic Adaptive over HTTP). */ + DYNAMIC_ADAPTIVE, + + /** HTTP Live Streaming (HLS). */ + HTTP_LIVE + } +} diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java index 7b3e44a1dcb5..ab20b30f42d0 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java @@ -10,98 +10,59 @@ import android.content.Context; import android.view.Surface; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.OptIn; import androidx.annotation.VisibleForTesting; import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; import androidx.media3.common.MediaItem; -import androidx.media3.common.MimeTypes; import androidx.media3.common.PlaybackParameters; -import androidx.media3.common.util.UnstableApi; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DefaultDataSource; -import androidx.media3.datasource.DefaultHttpDataSource; import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import io.flutter.view.TextureRegistry; -import java.util.Map; final class VideoPlayer { - private static final String FORMAT_SS = "ss"; - private static final String FORMAT_DASH = "dash"; - private static final String FORMAT_HLS = "hls"; - private static final String FORMAT_OTHER = "other"; - private ExoPlayer exoPlayer; - private Surface surface; - private final TextureRegistry.SurfaceTextureEntry textureEntry; - private final VideoPlayerCallbacks videoPlayerEvents; - - private static final String USER_AGENT = "User-Agent"; - private final VideoPlayerOptions options; - private final DefaultHttpDataSource.Factory httpDataSourceFactory; - - VideoPlayer( + /** + * Creates a video player. + * + * @param context application context. + * @param events event callbacks. + * @param textureEntry texture to render to. + * @param asset asset to play. + * @param options options for playback. + * @return a video player instance. + */ + @NonNull + static VideoPlayer create( Context context, VideoPlayerCallbacks events, TextureRegistry.SurfaceTextureEntry textureEntry, - String dataSource, - String formatHint, - @NonNull Map httpHeaders, + VideoAsset asset, VideoPlayerOptions options) { - this.videoPlayerEvents = events; - this.textureEntry = textureEntry; - this.options = options; - - MediaItem mediaItem = - new MediaItem.Builder() - .setUri(dataSource) - .setMimeType(mimeFromFormatHint(formatHint)) - .build(); - - httpDataSourceFactory = new DefaultHttpDataSource.Factory(); - configureHttpDataSourceFactory(httpHeaders); - - ExoPlayer exoPlayer = buildExoPlayer(context, httpDataSourceFactory); - - exoPlayer.setMediaItem(mediaItem); - exoPlayer.prepare(); - - setUpVideoPlayer(exoPlayer); + ExoPlayer.Builder builder = + new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context)); + return new VideoPlayer(builder, events, textureEntry, asset.getMediaItem(), options); } - // Constructor used to directly test members of this class. @VisibleForTesting VideoPlayer( - ExoPlayer exoPlayer, + ExoPlayer.Builder builder, VideoPlayerCallbacks events, TextureRegistry.SurfaceTextureEntry textureEntry, - VideoPlayerOptions options, - DefaultHttpDataSource.Factory httpDataSourceFactory) { + MediaItem mediaItem, + VideoPlayerOptions options) { this.videoPlayerEvents = events; this.textureEntry = textureEntry; this.options = options; - this.httpDataSourceFactory = httpDataSourceFactory; - setUpVideoPlayer(exoPlayer); - } + ExoPlayer exoPlayer = builder.build(); + exoPlayer.setMediaItem(mediaItem); + exoPlayer.prepare(); - @VisibleForTesting - public void configureHttpDataSourceFactory(@NonNull Map httpHeaders) { - final boolean httpHeadersNotEmpty = !httpHeaders.isEmpty(); - final String userAgent = - httpHeadersNotEmpty && httpHeaders.containsKey(USER_AGENT) - ? httpHeaders.get(USER_AGENT) - : "ExoPlayer"; - - unstableUpdateDataSourceFactory( - httpDataSourceFactory, httpHeaders, userAgent, httpHeadersNotEmpty); + setUpVideoPlayer(exoPlayer); } private void setUpVideoPlayer(ExoPlayer exoPlayer) { @@ -165,46 +126,4 @@ void dispose() { exoPlayer.release(); } } - - @NonNull - private static ExoPlayer buildExoPlayer( - Context context, DataSource.Factory baseDataSourceFactory) { - DataSource.Factory dataSourceFactory = - new DefaultDataSource.Factory(context, baseDataSourceFactory); - DefaultMediaSourceFactory mediaSourceFactory = - new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory); - return new ExoPlayer.Builder(context).setMediaSourceFactory(mediaSourceFactory).build(); - } - - @Nullable - private static String mimeFromFormatHint(@Nullable String formatHint) { - if (formatHint == null) { - return null; - } - switch (formatHint) { - case FORMAT_SS: - return MimeTypes.APPLICATION_SS; - case FORMAT_DASH: - return MimeTypes.APPLICATION_MPD; - case FORMAT_HLS: - return MimeTypes.APPLICATION_M3U8; - case FORMAT_OTHER: - default: - return null; - } - } - - // TODO: migrate to stable API, see https://github.com/flutter/flutter/issues/147039 - @OptIn(markerClass = UnstableApi.class) - private static void unstableUpdateDataSourceFactory( - DefaultHttpDataSource.Factory factory, - @NonNull Map httpHeaders, - String userAgent, - boolean httpHeadersNotEmpty) { - factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true); - - if (httpHeadersNotEmpty) { - factory.setDefaultRequestProperties(httpHeaders); - } - } } diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java index 72bff5cd18eb..8b83aded168f 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java @@ -97,6 +97,7 @@ public void onError(@NonNull String code, @Nullable String message, @Nullable Ob @Override public void onIsPlayingStateUpdate(boolean isPlaying) { Map event = new HashMap<>(); + event.put("event", "isPlayingStateUpdate"); event.put("isPlaying", isPlaying); eventSink.success(event); } diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java index 2d8c4439595a..0e57068944e1 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java @@ -24,7 +24,6 @@ import io.flutter.view.TextureRegistry; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; -import java.util.HashMap; import java.util.Map; import javax.net.ssl.HttpsURLConnection; @@ -101,7 +100,7 @@ public void initialize() { new EventChannel( flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + handle.id()); - VideoPlayer player; + final VideoAsset videoAsset; if (arg.getAsset() != null) { String assetLookupKey; if (arg.getPackageName() != null) { @@ -110,28 +109,34 @@ public void initialize() { } else { assetLookupKey = flutterState.keyForAsset.get(arg.getAsset()); } - player = - new VideoPlayer( - flutterState.applicationContext, - VideoPlayerEventCallbacks.bindTo(eventChannel), - handle, - "asset:///" + assetLookupKey, - null, - new HashMap<>(), - options); + videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey); } else { Map httpHeaders = arg.getHttpHeaders(); - player = - new VideoPlayer( - flutterState.applicationContext, - VideoPlayerEventCallbacks.bindTo(eventChannel), - handle, - arg.getUri(), - arg.getFormatHint(), - httpHeaders, - options); + VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.UNKNOWN; + String formatHint = arg.getFormatHint(); + if (formatHint != null) { + switch (formatHint) { + case "ss": + streamingFormat = VideoAsset.StreamingFormat.SMOOTH; + break; + case "dash": + streamingFormat = VideoAsset.StreamingFormat.DYNAMIC_ADAPTIVE; + break; + case "hls": + streamingFormat = VideoAsset.StreamingFormat.HTTP_LIVE; + break; + } + } + videoAsset = VideoAsset.fromRemoteUrl(arg.getUri(), streamingFormat, arg.getHttpHeaders()); } - videoPlayers.put(handle.id(), player); + videoPlayers.put( + handle.id(), + VideoPlayer.create( + flutterState.applicationContext, + VideoPlayerEventCallbacks.bindTo(eventChannel), + handle, + videoAsset, + options)); return new TextureMessage.Builder().setTextureId(handle.id()).build(); } diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTests.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTests.java new file mode 100644 index 000000000000..1d00d31b8ee5 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTests.java @@ -0,0 +1,175 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.common.VideoSize; +import androidx.media3.exoplayer.ExoPlayer; +import org.junit.Before; +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; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link ExoPlayerEventListener}. + * + *

This test suite narrowly verifies that the events emitted by the underlying {@link + * androidx.media3.exoplayer.ExoPlayer} instance are translated to the callback interface we expect + * ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected. + */ +@RunWith(RobolectricTestRunner.class) +public final class ExoPlayerEventListenerTests { + @Mock private ExoPlayer mockExoPlayer; + @Mock private VideoPlayerCallbacks mockCallbacks; + private ExoPlayerEventListener eventListener; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + @Before + public void setUp() { + eventListener = new ExoPlayerEventListener(mockExoPlayer, mockCallbacks); + } + + @Test + public void onPlaybackStateChangedReadySendInitialized() { + VideoSize size = new VideoSize(800, 400, 0, 0); + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 0); + } + + @Test + public void onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight() { + VideoSize size = new VideoSize(800, 400, 90, 0); + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(400, 800, 10L, 0); + } + + @Test + public void onPlaybackStateChangedReadyInPortraitMode270DegreesSwapWidthAndHeight() { + VideoSize size = new VideoSize(800, 400, 270, 0); + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(400, 800, 10L, 0); + } + + @Test + public void onPlaybackStateChangedReadyFlipped180DegreesInformEventHandler() { + VideoSize size = new VideoSize(800, 400, 180, 0); + when(mockExoPlayer.getVideoSize()).thenReturn(size); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + verify(mockCallbacks).onInitialized(800, 400, 10L, 180); + } + + @Test + public void onPlaybackStateChangedBufferingSendsBufferingStartAndUpdates() { + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING); + + verify(mockCallbacks).onBufferingStart(); + verify(mockCallbacks).onBufferingUpdate(10L); + verifyNoMoreInteractions(mockCallbacks); + + // If it's invoked again, only the update event is called. + verify(mockCallbacks).onBufferingUpdate(10L); + verifyNoMoreInteractions(mockCallbacks); + } + + @Test + public void onPlaybackStateChangedEndedSendsOnCompleted() { + eventListener.onPlaybackStateChanged(Player.STATE_ENDED); + + verify(mockCallbacks).onCompleted(); + verifyNoMoreInteractions(mockCallbacks); + } + + @Test + public void onPlaybackStateChangedEndedAfterBufferingSendsBufferingEndAndOnCompleted() { + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(mockCallbacks).onBufferingStart(); + verify(mockCallbacks).onBufferingUpdate(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_ENDED); + verify(mockCallbacks).onCompleted(); + verify(mockCallbacks).onBufferingEnd(); + + verifyNoMoreInteractions(mockCallbacks); + } + + @Test + public void onPlaybackStateChangedIdleDoNothing() { + eventListener.onPlaybackStateChanged(Player.STATE_IDLE); + + verifyNoInteractions(mockCallbacks); + } + + @Test + public void onPlaybackStateChangedIdleAfterBufferingSendsBufferingEnd() { + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(mockCallbacks).onBufferingStart(); + verify(mockCallbacks).onBufferingUpdate(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_IDLE); + verify(mockCallbacks).onBufferingEnd(); + + verifyNoMoreInteractions(mockCallbacks); + } + + @Test + public void onErrorVideoErrorWhenBufferingInProgressAlsoEndBuffering() { + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(mockCallbacks).onBufferingStart(); + verify(mockCallbacks).onBufferingUpdate(10L); + + eventListener.onPlayerError( + new PlaybackException("BAD", null, PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED)); + verify(mockCallbacks).onBufferingEnd(); + verify(mockCallbacks).onError(eq("VideoError"), contains("BAD"), isNull()); + } + + @Test + public void onErrorBehindLiveWindowSeekToDefaultAndPrepare() { + eventListener.onPlayerError( + new PlaybackException("SORT_OF_OK", null, PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW)); + + verify(mockExoPlayer).seekToDefaultPosition(); + verify(mockExoPlayer).prepare(); + verifyNoInteractions(mockCallbacks); + } + + @Test + public void onIsPlayingChangedToggled() { + eventListener.onIsPlayingChanged(true); + verify(mockCallbacks).onIsPlayingStateUpdate(true); + + eventListener.onIsPlayingChanged(false); + verify(mockCallbacks).onIsPlayingStateUpdate(false); + } +} diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java new file mode 100644 index 000000000000..1e3b856a6482 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.test.utils.FakeMediaSourceFactory; + +/** A fake implementation of the {@link VideoAsset} class. */ +final class FakeVideoAsset extends VideoAsset { + @NonNull private final MediaSource.Factory mediaSourceFactory; + + FakeVideoAsset(String assetUrl) { + this(assetUrl, new FakeMediaSourceFactory()); + } + + FakeVideoAsset(String assetUrl, @NonNull MediaSource.Factory mediaSourceFactory) { + super(assetUrl); + this.mediaSourceFactory = mediaSourceFactory; + } + + @NonNull + @Override + MediaItem getMediaItem() { + return new MediaItem.Builder().setUri(assetUrl).build(); + } + + @Override + MediaSource.Factory getMediaSourceFactory(Context context) { + return mediaSourceFactory; + } +} diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java new file mode 100644 index 000000000000..743bf572aee2 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java @@ -0,0 +1,138 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.net.Uri; +import androidx.media3.common.MediaItem; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.test.core.app.ApplicationProvider; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests for {@link VideoAsset}. + * + *

This test suite narrowly verifies that the {@link VideoAsset} factory methods, {@link + * VideoAsset#fromRemoteUrl(String, VideoAsset.StreamingFormat, Map)} and {@link + * VideoAsset#fromAssetUrl(String)} follow the contract they have documented. + * + *

In other tests of the player, a fake asset is likely to be used. + */ +@RunWith(RobolectricTestRunner.class) +public final class VideoAssetTest { + @Test + public void localVideoRequiresAssetUrl() { + assertThrows( + IllegalArgumentException.class, + () -> VideoAsset.fromAssetUrl("https://not.local/video.mp4")); + } + + @Test + public void localVideoCreatesMediaItem() { + VideoAsset asset = VideoAsset.fromAssetUrl("asset:///asset-key"); + MediaItem mediaItem = asset.getMediaItem(); + + assert mediaItem.localConfiguration != null; + assertEquals(mediaItem.localConfiguration.uri, Uri.parse("asset:///asset-key")); + } + + private static DefaultHttpDataSource.Factory mockHttpFactory() { + DefaultHttpDataSource.Factory httpFactory = mock(DefaultHttpDataSource.Factory.class); + when(httpFactory.setUserAgent(anyString())).thenReturn(httpFactory); + when(httpFactory.setAllowCrossProtocolRedirects(anyBoolean())).thenReturn(httpFactory); + when(httpFactory.setDefaultRequestProperties(anyMap())).thenReturn(httpFactory); + return httpFactory; + } + + @Test + public void remoteVideoByDefaultSetsUserAgentAndCrossProtocolRedirects() { + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, new HashMap<>()); + + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); + + // Cast to RemoteVideoAsset to call a testing-only method to intercept calls. + ((RemoteVideoAsset) asset) + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + verify(mockFactory).setUserAgent("ExoPlayer"); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory, never()).setDefaultRequestProperties(anyMap()); + } + + @Test + public void remoteVideoOverridesUserAgentIfProvided() { + Map headers = new HashMap<>(); + headers.put("User-Agent", "FantasticalVideoBot"); + + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, headers); + + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); + + // Cast to RemoteVideoAsset to call a testing-only method to intercept calls. + ((RemoteVideoAsset) asset) + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + verify(mockFactory).setUserAgent("FantasticalVideoBot"); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory).setDefaultRequestProperties(headers); + } + + // This tests that without using the overrides we get a working, non-mocked object. + // + // I guess you could also start a local HTTP server, and try fetching with it, YMMV. + @Test + public void remoteVideoGetMediaSourceFactoryInProductionReturnsRealMediaSource() { + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, new HashMap<>()); + + MediaSource source = + asset + .getMediaSourceFactory(ApplicationProvider.getApplicationContext()) + .createMediaSource(asset.getMediaItem()); + assertEquals( + Uri.parse("https://flutter.dev/video.mp4"), + Objects.requireNonNull(source.getMediaItem().localConfiguration).uri); + } + + @Test + public void remoteVideoSetsAdditionalHttpHeadersIfProvided() { + Map headers = new HashMap<>(); + headers.put("X-Cache-Forever", "true"); + + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, headers); + + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); + + // Cast to RemoteVideoAsset to call a testing-only method to intercept calls. + ((RemoteVideoAsset) asset) + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + verify(mockFactory).setUserAgent("ExoPlayer"); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory).setDefaultRequestProperties(headers); + } +} diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java new file mode 100644 index 000000000000..d955384bc94f --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java @@ -0,0 +1,151 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** + * Unit tests {@link VideoPlayerEventCallbacks}. + * + *

This test suite narrowly verifies that calling the provided event callbacks, such as + * {@link VideoPlayerEventCallbacks#onBufferingUpdate(long)}, produces the expected data as an + * encoded {@link Map}. + * + *

In other words, this tests that "the Java-side of the event channel works as expected". + */ +@RunWith(RobolectricTestRunner.class) +public final class VideoPlayerEventCallbacksTest { + private VideoPlayerEventCallbacks eventCallbacks; + + @Mock private QueuingEventSink mockEventSink; + + @Captor private ArgumentCaptor> eventCaptor; + + @Rule public MockitoRule initRule = MockitoJUnit.rule(); + + @Before + public void setUp() { + eventCallbacks = VideoPlayerEventCallbacks.withSink(mockEventSink); + } + + @Test + public void onInitializedSendsWidthHeightAndDuration() { + eventCallbacks.onInitialized(800, 400, 10L, 0); + + verify(mockEventSink).success(eventCaptor.capture()); + + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "initialized"); + expected.put("duration", 10L); + expected.put("width", 800); + expected.put("height", 400); + + assertEquals(expected, actual); + } + + @Test + public void onInitializedIncludesRotationCorrectIfNonZero() { + eventCallbacks.onInitialized(800, 400, 10L, 180); + + verify(mockEventSink).success(eventCaptor.capture()); + + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "initialized"); + expected.put("duration", 10L); + expected.put("width", 800); + expected.put("height", 400); + expected.put("rotationCorrection", 180); + + assertEquals(expected, actual); + } + + @Test + public void onBufferingStart() { + eventCallbacks.onBufferingStart(); + + verify(mockEventSink).success(eventCaptor.capture()); + + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "bufferingStart"); + assertEquals(expected, actual); + } + + @Test + public void onBufferingUpdateProvidesAListWithASingleRange() { + eventCallbacks.onBufferingUpdate(10L); + + verify(mockEventSink).success(eventCaptor.capture()); + + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "bufferingUpdate"); + expected.put("values", Collections.singletonList(Arrays.asList(0, 10L))); + assertEquals(expected, actual); + } + + @Test + public void onBufferingEnd() { + eventCallbacks.onBufferingEnd(); + + verify(mockEventSink).success(eventCaptor.capture()); + + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "bufferingEnd"); + assertEquals(expected, actual); + } + + @Test + public void onCompleted() { + eventCallbacks.onCompleted(); + + verify(mockEventSink).success(eventCaptor.capture()); + + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "completed"); + assertEquals(expected, actual); + } + + @Test + public void onError() { + eventCallbacks.onError("code", "message", "details"); + + verify(mockEventSink).error(eq("code"), eq("message"), eq("details")); + } + + @Test + public void onIsPlayingStateUpdate() { + eventCallbacks.onIsPlayingStateUpdate(true); + + verify(mockEventSink).success(eventCaptor.capture()); + + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "isPlayingStateUpdate"); + expected.put("isPlaying", true); + assertEquals(expected, actual); + } +} diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java index d854601ff3fa..a30166c0aa51 100644 --- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java @@ -5,355 +5,176 @@ package io.flutter.plugins.videoplayer; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.*; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; import android.graphics.SurfaceTexture; -import androidx.media3.common.PlaybackException; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.PlaybackParameters; import androidx.media3.common.Player; -import androidx.media3.common.VideoSize; -import androidx.media3.datasource.DefaultHttpDataSource; import androidx.media3.exoplayer.ExoPlayer; import io.flutter.view.TextureRegistry; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; -import org.mockito.MockitoAnnotations; -import org.mockito.stubbing.Answer; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; +/** + * Unit tests for {@link VideoPlayer}. + * + *

This test suite narrowly verifies that {@link VideoPlayer} interfaces with the {@link + * ExoPlayer} interface exactly as it did when the test suite was created. That is, if the + * behavior changes, this test will need to change. However, this suite should catch bugs related to + * "this is a safe refactor with no behavior changes". + * + *

It's hypothetically possible to write better tests using {@link + * androidx.media3.test.utils.FakeMediaSource}, but you really need a PhD in the Android media APIs + * in order to figure out how to set everything up so the player "works". + */ @RunWith(RobolectricTestRunner.class) -public class VideoPlayerTest { - private ExoPlayer fakeExoPlayer; - private TextureRegistry.SurfaceTextureEntry fakeSurfaceTextureEntry; - private VideoPlayerOptions fakeVideoPlayerOptions; - private QueuingEventSink fakeEventSink; - private DefaultHttpDataSource.Factory httpDataSourceFactorySpy; +public final class VideoPlayerTest { + private static final String FAKE_ASSET_URL = "https://flutter.dev/movie.mp4"; + private FakeVideoAsset fakeVideoAsset; - @Captor private ArgumentCaptor> eventCaptor; + @Mock private VideoPlayerCallbacks mockEvents; + @Mock private TextureRegistry.SurfaceTextureEntry mockTexture; + @Mock private ExoPlayer.Builder mockBuilder; + @Mock private ExoPlayer mockExoPlayer; + @Captor private ArgumentCaptor attributesCaptor; - private AutoCloseable mocks; + @Rule public MockitoRule initRule = MockitoJUnit.rule(); @Before - public void before() { - mocks = MockitoAnnotations.openMocks(this); - - fakeExoPlayer = mock(ExoPlayer.class); - fakeSurfaceTextureEntry = mock(TextureRegistry.SurfaceTextureEntry.class); - SurfaceTexture fakeSurfaceTexture = mock(SurfaceTexture.class); - when(fakeSurfaceTextureEntry.surfaceTexture()).thenReturn(fakeSurfaceTexture); - fakeVideoPlayerOptions = mock(VideoPlayerOptions.class); - fakeEventSink = mock(QueuingEventSink.class); - httpDataSourceFactorySpy = spy(new DefaultHttpDataSource.Factory()); + public void setUp() { + fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL); + when(mockBuilder.build()).thenReturn(mockExoPlayer); + when(mockTexture.surfaceTexture()).thenReturn(mock(SurfaceTexture.class)); } - @After - public void after() throws Exception { - mocks.close(); + private VideoPlayer createVideoPlayer() { + return createVideoPlayer(new VideoPlayerOptions()); } - @Test - public void videoPlayer_buildsHttpDataSourceFactoryProperlyWhenHttpHeadersNull() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); - - videoPlayer.configureHttpDataSourceFactory(new HashMap<>()); - - verify(httpDataSourceFactorySpy).setUserAgent("ExoPlayer"); - verify(httpDataSourceFactorySpy).setAllowCrossProtocolRedirects(true); - verify(httpDataSourceFactorySpy, never()).setDefaultRequestProperties(any()); + private VideoPlayer createVideoPlayer(VideoPlayerOptions options) { + return new VideoPlayer( + mockBuilder, mockEvents, mockTexture, fakeVideoAsset.getMediaItem(), options); } @Test - public void - videoPlayer_buildsHttpDataSourceFactoryProperlyWhenHttpHeadersNonNullAndUserAgentSpecified() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); - Map httpHeaders = - new HashMap() { - { - put("header", "value"); - put("User-Agent", "userAgent"); - } - }; - - videoPlayer.configureHttpDataSourceFactory(httpHeaders); - - verify(httpDataSourceFactorySpy).setUserAgent("userAgent"); - verify(httpDataSourceFactorySpy).setAllowCrossProtocolRedirects(true); - verify(httpDataSourceFactorySpy).setDefaultRequestProperties(httpHeaders); - } + public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() { + VideoPlayer videoPlayer = createVideoPlayer(); - @Test - public void - videoPlayer_buildsHttpDataSourceFactoryProperlyWhenHttpHeadersNonNullAndUserAgentNotSpecified() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); - Map httpHeaders = - new HashMap() { - { - put("header", "value"); - } - }; - - videoPlayer.configureHttpDataSourceFactory(httpHeaders); - - verify(httpDataSourceFactorySpy).setUserAgent("ExoPlayer"); - verify(httpDataSourceFactorySpy).setAllowCrossProtocolRedirects(true); - verify(httpDataSourceFactorySpy).setDefaultRequestProperties(httpHeaders); - } + verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem()); + verify(mockExoPlayer).prepare(); + verify(mockTexture).surfaceTexture(); + verify(mockExoPlayer).setVideoSurface(any()); - private Player.Listener initVideoPlayerAndGetListener() { - ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(Player.Listener.class); - doNothing().when(fakeExoPlayer).addListener(listenerCaptor.capture()); + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true)); + assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); - // Create a video player that will invoke fakeEventSink as a result of Player.Listener calls. - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); - - return Objects.requireNonNull(listenerCaptor.getValue()); + videoPlayer.dispose(); } @Test - public void onPlaybackStateBufferingSendBufferedPositionUpdate() { - Player.Listener listener = initVideoPlayerAndGetListener(); - when(fakeExoPlayer.getBufferedPosition()).thenReturn(10L); - - // Send Player.STATE_BUFFERING to trigger the "bufferingUpdate" event. - listener.onPlaybackStateChanged(Player.STATE_BUFFERING); - - verify(fakeEventSink, atLeast(1)).success(eventCaptor.capture()); - List> events = eventCaptor.getAllValues(); + public void loadsAndPreparesProvidedMediaDisablesAudioFocusWhenMixModeSet() { + VideoPlayerOptions options = new VideoPlayerOptions(); + options.mixWithOthers = true; - Map expected = new HashMap<>(); - expected.put("event", "bufferingUpdate"); + VideoPlayer videoPlayer = createVideoPlayer(options); - List range = Arrays.asList(0, 10L); - expected.put("values", Collections.singletonList(range)); + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(false)); + assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); - // We received potentially multiple events, find the one that is a "bufferingUpdate". - for (Map event : events) { - if (event.get("event") == "bufferingUpdate") { - assertEquals(expected, event); - return; - } - } - - fail("No 'bufferingUpdate' event found: " + events); + videoPlayer.dispose(); } @Test - public void sendInitializedSendsExpectedEvent_90RotationDegrees() { - Player.Listener listener = initVideoPlayerAndGetListener(); - VideoSize testVideoSize = new VideoSize(100, 200, 90, 1f); - - when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); - when(fakeExoPlayer.getDuration()).thenReturn(10L); - - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); + public void playsAndPausesProvidedMedia() { + VideoPlayer videoPlayer = createVideoPlayer(); - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap actual = eventCaptor.getValue(); + videoPlayer.play(); + verify(mockExoPlayer).setPlayWhenReady(true); - Map expected = new HashMap<>(); - expected.put("event", "initialized"); - expected.put("duration", 10L); - expected.put("width", 200); - expected.put("height", 100); + videoPlayer.pause(); + verify(mockExoPlayer).setPlayWhenReady(false); - assertEquals(expected, actual); + videoPlayer.dispose(); } @Test - public void sendInitializedSendsExpectedEvent_270RotationDegrees() { - Player.Listener listener = initVideoPlayerAndGetListener(); - VideoSize testVideoSize = new VideoSize(100, 200, 270, 1f); - - when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); - when(fakeExoPlayer.getDuration()).thenReturn(10L); + public void sendsBufferingUpdatesOnDemand() { + VideoPlayer videoPlayer = createVideoPlayer(); - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); + videoPlayer.sendBufferingUpdate(); + verify(mockEvents).onBufferingUpdate(10L); - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap actual = eventCaptor.getValue(); - - Map expected = new HashMap<>(); - expected.put("event", "initialized"); - expected.put("duration", 10L); - expected.put("width", 200); - expected.put("height", 100); - - assertEquals(expected, actual); + videoPlayer.dispose(); } @Test - public void sendInitializedSendsExpectedEvent_0RotationDegrees() { - Player.Listener listener = initVideoPlayerAndGetListener(); - VideoSize testVideoSize = new VideoSize(100, 200, 0, 1f); - - when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); - when(fakeExoPlayer.getDuration()).thenReturn(10L); + public void togglesLoopingEnablesAndDisablesRepeatMode() { + VideoPlayer videoPlayer = createVideoPlayer(); - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); + videoPlayer.setLooping(true); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_ALL); - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap actual = eventCaptor.getValue(); + videoPlayer.setLooping(false); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_OFF); - Map expected = new HashMap<>(); - expected.put("event", "initialized"); - expected.put("duration", 10L); - expected.put("width", 100); - expected.put("height", 200); - - assertEquals(expected, actual); + videoPlayer.dispose(); } @Test - public void sendInitializedSendsExpectedEvent_180RotationDegrees() { - Player.Listener listener = initVideoPlayerAndGetListener(); - VideoSize testVideoSize = new VideoSize(100, 200, 180, 1f); - - when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); - when(fakeExoPlayer.getDuration()).thenReturn(10L); + public void setVolumeIsClampedBetween0and1() { + VideoPlayer videoPlayer = createVideoPlayer(); - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); + videoPlayer.setVolume(-1.0); + verify(mockExoPlayer).setVolume(0f); - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap actual = eventCaptor.getValue(); + videoPlayer.setVolume(2.0); + verify(mockExoPlayer).setVolume(1f); - Map expected = new HashMap<>(); - expected.put("event", "initialized"); - expected.put("duration", 10L); - expected.put("width", 100); - expected.put("height", 200); - expected.put("rotationCorrection", 180); + videoPlayer.setVolume(0.5); + verify(mockExoPlayer).setVolume(0.5f); - assertEquals(expected, actual); + videoPlayer.dispose(); } @Test - public void onIsPlayingChangedSendsExpectedEvent() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); - - doAnswer( - (Answer) - invocation -> { - Map event = new HashMap<>(); - event.put("event", "isPlayingStateUpdate"); - event.put("isPlaying", invocation.getArguments()[0]); - fakeEventSink.success(event); - return null; - }) - .when(fakeExoPlayer) - .setPlayWhenReady(anyBoolean()); - - videoPlayer.play(); + public void setPlaybackSpeedSetsPlaybackParametersWithValue() { + VideoPlayer videoPlayer = createVideoPlayer(); - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap event1 = eventCaptor.getValue(); + videoPlayer.setPlaybackSpeed(2.5); + verify(mockExoPlayer).setPlaybackParameters(new PlaybackParameters(2.5f)); - assertEquals(event1.get("event"), "isPlayingStateUpdate"); - assertEquals(event1.get("isPlaying"), true); + videoPlayer.dispose(); + } - videoPlayer.pause(); + @Test + public void seekAndGetPosition() { + VideoPlayer videoPlayer = createVideoPlayer(); - verify(fakeEventSink, times(2)).success(eventCaptor.capture()); - HashMap event2 = eventCaptor.getValue(); + videoPlayer.seekTo(10); + verify(mockExoPlayer).seekTo(10); - assertEquals(event2.get("event"), "isPlayingStateUpdate"); - assertEquals(event2.get("isPlaying"), false); + when(mockExoPlayer.getCurrentPosition()).thenReturn(20L); + assertEquals(20L, videoPlayer.getPosition()); } @Test - public void behindLiveWindowErrorResetsPlayerToDefaultPosition() { - List listeners = new LinkedList<>(); - doAnswer(invocation -> listeners.add(invocation.getArgument(0))) - .when(fakeExoPlayer) - .addListener(any()); - - @SuppressWarnings("unused") - VideoPlayer unused = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); - - PlaybackException exception = - new PlaybackException(null, null, PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW); - listeners.forEach(listener -> listener.onPlayerError(exception)); - - verify(fakeExoPlayer).seekToDefaultPosition(); - verify(fakeExoPlayer).prepare(); - } + public void disposeReleasesTextureAndPlayer() { + VideoPlayer videoPlayer = createVideoPlayer(); + videoPlayer.dispose(); - @Test - public void otherErrorsReportVideoErrorWithErrorString() { - List listeners = new LinkedList<>(); - doAnswer(invocation -> listeners.add(invocation.getArgument(0))) - .when(fakeExoPlayer) - .addListener(any()); - - @SuppressWarnings("unused") - VideoPlayer unused = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); - - PlaybackException exception = - new PlaybackException( - "You did bad kid", null, PlaybackException.ERROR_CODE_DECODING_FAILED); - listeners.forEach(listener -> listener.onPlayerError(exception)); - - verify(fakeEventSink).error(eq("VideoError"), contains("You did bad kid"), any()); + verify(mockTexture).release(); + verify(mockExoPlayer).release(); } }