From fea9087cbffe709a420e69ad678a281495087ebd Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Mon, 24 Jun 2024 21:27:27 -0700 Subject: [PATCH 01/14] Further refactor and add test coverage for video_player_android. --- .../video_player_android/android/build.gradle | 4 + .../plugins/videoplayer/VideoAsset.java | 210 ++++++++ .../plugins/videoplayer/VideoPlayer.java | 125 +---- .../VideoPlayerEventCallbacks.java | 1 + .../videoplayer/VideoPlayerPlugin.java | 403 ++++++++------- .../ExoPlayerEventListenerTests.java | 177 +++++++ .../plugins/videoplayer/FakeVideoAsset.java | 55 ++ .../plugins/videoplayer/VideoAssetTest.java | 115 +++++ .../VideoPlayerEventCallbacksTest.java | 151 ++++++ .../plugins/videoplayer/VideoPlayerTest.java | 485 ++++++------------ 10 files changed, 1102 insertions(+), 624 deletions(-) create mode 100644 packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java create mode 100644 packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTests.java create mode 100644 packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java create mode 100644 packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java create mode 100644 packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle index 77f504de422..e81de638d55 100644 --- a/packages/video_player/video_player_android/android/build.gradle +++ b/packages/video_player/video_player_android/android/build.gradle @@ -71,3 +71,7 @@ android { } } } + +dependencies { + implementation 'androidx.media3:media3-test-utils:1.3.1' +} 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 00000000000..a48080aa999 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java @@ -0,0 +1,210 @@ +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.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 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 + abstract MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory initialFactory); + + /** + * 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); + + private static final class LocalVideoAsset extends VideoAsset { + private 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); + } + + @Override + MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory initialFactory) { + return new DefaultMediaSourceFactory(context); + } + } + + private static 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; + + private 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 DynamicAdaptive: + mimeType = MimeTypes.APPLICATION_MPD; + break; + case HttpLive: + 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()); + } + + @Override + MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory factory) { + String userAgent = DEFAULT_USER_AGENT; + if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) { + userAgent = httpHeaders.get(HEADER_USER_AGENT); + } + unstableUpdateDataSourceFactory(factory, httpHeaders, userAgent); + + DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, factory); + 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); + } + } + } + + /** + * 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). + */ + DynamicAdaptive, + + /** + * HTTP Live Streaming (HLS). + */ + HttpLive + } +} 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 7b3e44a1dcb..21d156e4c04 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,63 @@ 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) { - this.videoPlayerEvents = events; + 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 +130,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 72bff5cd18e..8b83aded168 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 2d8c4439595..1092126c741 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 @@ -7,7 +7,9 @@ import android.content.Context; import android.os.Build; import android.util.LongSparseArray; + import androidx.annotation.NonNull; + import io.flutter.FlutterInjector; import io.flutter.Log; import io.flutter.embedding.engine.plugins.FlutterPlugin; @@ -22,206 +24,217 @@ import io.flutter.plugins.videoplayer.Messages.TextureMessage; import io.flutter.plugins.videoplayer.Messages.VolumeMessage; 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; -/** Android platform implementation of the VideoPlayerPlugin. */ +/** + * Android platform implementation of the VideoPlayerPlugin. + */ public class VideoPlayerPlugin implements FlutterPlugin, AndroidVideoPlayerApi { - private static final String TAG = "VideoPlayerPlugin"; - private final LongSparseArray videoPlayers = new LongSparseArray<>(); - private FlutterState flutterState; - private final VideoPlayerOptions options = new VideoPlayerOptions(); - - /** Register this with the v2 embedding for the plugin to respond to lifecycle callbacks. */ - public VideoPlayerPlugin() {} - - @Override - public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { - if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - try { - HttpsURLConnection.setDefaultSSLSocketFactory(new CustomSSLSocketFactory()); - } catch (KeyManagementException | NoSuchAlgorithmException e) { - Log.w( - TAG, - "Failed to enable TLSv1.1 and TLSv1.2 Protocols for API level 19 and below.\n" - + "For more information about Socket Security, please consult the following link:\n" - + "https://developer.android.com/reference/javax/net/ssl/SSLSocket", - e); - } - } - - final FlutterInjector injector = FlutterInjector.instance(); - this.flutterState = - new FlutterState( - binding.getApplicationContext(), - binding.getBinaryMessenger(), - injector.flutterLoader()::getLookupKeyForAsset, - injector.flutterLoader()::getLookupKeyForAsset, - binding.getTextureRegistry()); - flutterState.startListening(this, binding.getBinaryMessenger()); - } - - @Override - public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { - if (flutterState == null) { - Log.wtf(TAG, "Detached from the engine before registering to it."); - } - flutterState.stopListening(binding.getBinaryMessenger()); - flutterState = null; - onDestroy(); - } - - private void disposeAllPlayers() { - for (int i = 0; i < videoPlayers.size(); i++) { - videoPlayers.valueAt(i).dispose(); - } - videoPlayers.clear(); - } - - public void onDestroy() { - // The whole FlutterView is being destroyed. Here we release resources acquired for all - // instances - // of VideoPlayer. Once https://github.com/flutter/flutter/issues/19358 is resolved this may - // be replaced with just asserting that videoPlayers.isEmpty(). - // https://github.com/flutter/flutter/issues/20989 tracks this. - disposeAllPlayers(); - } - - public void initialize() { - disposeAllPlayers(); - } - - public @NonNull TextureMessage create(@NonNull CreateMessage arg) { - TextureRegistry.SurfaceTextureEntry handle = - flutterState.textureRegistry.createSurfaceTexture(); - EventChannel eventChannel = - new EventChannel( - flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + handle.id()); - - VideoPlayer player; - if (arg.getAsset() != null) { - String assetLookupKey; - if (arg.getPackageName() != null) { - assetLookupKey = - flutterState.keyForAssetAndPackageName.get(arg.getAsset(), arg.getPackageName()); - } else { - assetLookupKey = flutterState.keyForAsset.get(arg.getAsset()); - } - player = - new VideoPlayer( - flutterState.applicationContext, - VideoPlayerEventCallbacks.bindTo(eventChannel), - handle, - "asset:///" + assetLookupKey, - null, - new HashMap<>(), - options); - } else { - Map httpHeaders = arg.getHttpHeaders(); - player = - new VideoPlayer( - flutterState.applicationContext, - VideoPlayerEventCallbacks.bindTo(eventChannel), - handle, - arg.getUri(), - arg.getFormatHint(), - httpHeaders, - options); - } - videoPlayers.put(handle.id(), player); - - return new TextureMessage.Builder().setTextureId(handle.id()).build(); - } - - public void dispose(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.dispose(); - videoPlayers.remove(arg.getTextureId()); - } - - public void setLooping(@NonNull LoopingMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.setLooping(arg.getIsLooping()); - } - - public void setVolume(@NonNull VolumeMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.setVolume(arg.getVolume()); - } - - public void setPlaybackSpeed(@NonNull PlaybackSpeedMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.setPlaybackSpeed(arg.getSpeed()); - } - - public void play(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.play(); - } - - public @NonNull PositionMessage position(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - PositionMessage result = - new PositionMessage.Builder() - .setPosition(player.getPosition()) - .setTextureId(arg.getTextureId()) - .build(); - player.sendBufferingUpdate(); - return result; - } - - public void seekTo(@NonNull PositionMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.seekTo(arg.getPosition().intValue()); - } - - public void pause(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.pause(); - } - - @Override - public void setMixWithOthers(@NonNull MixWithOthersMessage arg) { - options.mixWithOthers = arg.getMixWithOthers(); - } - - private interface KeyForAssetFn { - String get(String asset); - } - - private interface KeyForAssetAndPackageName { - String get(String asset, String packageName); - } - - private static final class FlutterState { - final Context applicationContext; - final BinaryMessenger binaryMessenger; - final KeyForAssetFn keyForAsset; - final KeyForAssetAndPackageName keyForAssetAndPackageName; - final TextureRegistry textureRegistry; - - FlutterState( - Context applicationContext, - BinaryMessenger messenger, - KeyForAssetFn keyForAsset, - KeyForAssetAndPackageName keyForAssetAndPackageName, - TextureRegistry textureRegistry) { - this.applicationContext = applicationContext; - this.binaryMessenger = messenger; - this.keyForAsset = keyForAsset; - this.keyForAssetAndPackageName = keyForAssetAndPackageName; - this.textureRegistry = textureRegistry; - } - - void startListening(VideoPlayerPlugin methodCallHandler, BinaryMessenger messenger) { - AndroidVideoPlayerApi.setup(messenger, methodCallHandler); - } - - void stopListening(BinaryMessenger messenger) { - AndroidVideoPlayerApi.setup(messenger, null); - } - } + private static final String TAG = "VideoPlayerPlugin"; + private final LongSparseArray videoPlayers = new LongSparseArray<>(); + private FlutterState flutterState; + private final VideoPlayerOptions options = new VideoPlayerOptions(); + + /** + * Register this with the v2 embedding for the plugin to respond to lifecycle callbacks. + */ + public VideoPlayerPlugin() { + } + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + try { + HttpsURLConnection.setDefaultSSLSocketFactory(new CustomSSLSocketFactory()); + } catch (KeyManagementException | NoSuchAlgorithmException e) { + Log.w( + TAG, + "Failed to enable TLSv1.1 and TLSv1.2 Protocols for API level 19 and below.\n" + + "For more information about Socket Security, please consult the following link:\n" + + "https://developer.android.com/reference/javax/net/ssl/SSLSocket", + e); + } + } + + final FlutterInjector injector = FlutterInjector.instance(); + this.flutterState = + new FlutterState( + binding.getApplicationContext(), + binding.getBinaryMessenger(), + injector.flutterLoader()::getLookupKeyForAsset, + injector.flutterLoader()::getLookupKeyForAsset, + binding.getTextureRegistry()); + flutterState.startListening(this, binding.getBinaryMessenger()); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + if (flutterState == null) { + Log.wtf(TAG, "Detached from the engine before registering to it."); + } + flutterState.stopListening(binding.getBinaryMessenger()); + flutterState = null; + onDestroy(); + } + + private void disposeAllPlayers() { + for (int i = 0; i < videoPlayers.size(); i++) { + videoPlayers.valueAt(i).dispose(); + } + videoPlayers.clear(); + } + + public void onDestroy() { + // The whole FlutterView is being destroyed. Here we release resources acquired for all + // instances + // of VideoPlayer. Once https://github.com/flutter/flutter/issues/19358 is resolved this may + // be replaced with just asserting that videoPlayers.isEmpty(). + // https://github.com/flutter/flutter/issues/20989 tracks this. + disposeAllPlayers(); + } + + public void initialize() { + disposeAllPlayers(); + } + + public @NonNull TextureMessage create(@NonNull CreateMessage arg) { + TextureRegistry.SurfaceTextureEntry handle = + flutterState.textureRegistry.createSurfaceTexture(); + EventChannel eventChannel = + new EventChannel( + flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + handle.id()); + + final VideoAsset videoAsset; + if (arg.getAsset() != null) { + String assetLookupKey; + if (arg.getPackageName() != null) { + assetLookupKey = + flutterState.keyForAssetAndPackageName.get(arg.getAsset(), arg.getPackageName()); + } else { + assetLookupKey = flutterState.keyForAsset.get(arg.getAsset()); + } + videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey); + } else { + Map httpHeaders = arg.getHttpHeaders(); + 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.DynamicAdaptive; + break; + case "hls": + streamingFormat = VideoAsset.StreamingFormat.HttpLive; + break; + } + } + videoAsset = VideoAsset.fromRemoteUrl(arg.getUri(), streamingFormat, arg.getHttpHeaders()); + } + videoPlayers.put(handle.id(), VideoPlayer.create( + flutterState.applicationContext, + VideoPlayerEventCallbacks.bindTo(eventChannel), + handle, + videoAsset, + options)); + + return new TextureMessage.Builder().setTextureId(handle.id()).build(); + } + + public void dispose(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.dispose(); + videoPlayers.remove(arg.getTextureId()); + } + + public void setLooping(@NonNull LoopingMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.setLooping(arg.getIsLooping()); + } + + public void setVolume(@NonNull VolumeMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.setVolume(arg.getVolume()); + } + + public void setPlaybackSpeed(@NonNull PlaybackSpeedMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.setPlaybackSpeed(arg.getSpeed()); + } + + public void play(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.play(); + } + + public @NonNull PositionMessage position(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + PositionMessage result = + new PositionMessage.Builder() + .setPosition(player.getPosition()) + .setTextureId(arg.getTextureId()) + .build(); + player.sendBufferingUpdate(); + return result; + } + + public void seekTo(@NonNull PositionMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.seekTo(arg.getPosition().intValue()); + } + + public void pause(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.pause(); + } + + @Override + public void setMixWithOthers(@NonNull MixWithOthersMessage arg) { + options.mixWithOthers = arg.getMixWithOthers(); + } + + private interface KeyForAssetFn { + String get(String asset); + } + + private interface KeyForAssetAndPackageName { + String get(String asset, String packageName); + } + + private static final class FlutterState { + final Context applicationContext; + final BinaryMessenger binaryMessenger; + final KeyForAssetFn keyForAsset; + final KeyForAssetAndPackageName keyForAssetAndPackageName; + final TextureRegistry textureRegistry; + + FlutterState( + Context applicationContext, + BinaryMessenger messenger, + KeyForAssetFn keyForAsset, + KeyForAssetAndPackageName keyForAssetAndPackageName, + TextureRegistry textureRegistry) { + this.applicationContext = applicationContext; + this.binaryMessenger = messenger; + this.keyForAsset = keyForAsset; + this.keyForAssetAndPackageName = keyForAssetAndPackageName; + this.textureRegistry = textureRegistry; + } + + void startListening(VideoPlayerPlugin methodCallHandler, BinaryMessenger messenger) { + AndroidVideoPlayerApi.setup(messenger, methodCallHandler); + } + + void stopListening(BinaryMessenger messenger) { + AndroidVideoPlayerApi.setup(messenger, null); + } + } } 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 00000000000..392feb0febe --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTests.java @@ -0,0 +1,177 @@ +package io.flutter.plugins.videoplayer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +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 00000000000..9741e47bb5b --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java @@ -0,0 +1,55 @@ +package io.flutter.plugins.videoplayer; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.test.utils.FakeMediaSource; +import androidx.media3.test.utils.FakeMediaSourceFactory; +import androidx.media3.test.utils.FakeTimeline; +import androidx.media3.test.utils.FakeTrackOutput; + +import com.google.common.collect.Lists; + +import java.time.Duration; +import java.util.Collections; + +/** + * 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; + } + + @Override + MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory initialFactory) { + return getMediaSourceFactory(context); + } +} 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 00000000000..51700eb6faa --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java @@ -0,0 +1,115 @@ +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.test.core.app.ApplicationProvider; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.HashMap; +import java.util.Map; + +/** + * 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() throws Exception { + VideoAsset asset = VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", + VideoAsset.StreamingFormat.Unknown, + new HashMap<>()); + + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); + asset.getMediaSourceFactory( + ApplicationProvider.getApplicationContext(), + mockFactory); + + verify(mockFactory).setUserAgent("ExoPlayer"); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory, never()).setDefaultRequestProperties(anyMap()); + } + + @Test + public void remoteVideoOverridesUserAgentIfProvided() throws Exception { + 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(); + asset.getMediaSourceFactory( + ApplicationProvider.getApplicationContext(), + mockFactory); + + verify(mockFactory).setUserAgent("FantasticalVideoBot"); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory).setDefaultRequestProperties(headers); + } + + @Test + public void remoteVideoSetsAdditionalHttpHeadersIfProvided() throws Exception { + 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(); + 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 00000000000..4809c7c3872 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java @@ -0,0 +1,151 @@ +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +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; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * 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 d854601ff3f..381b168b478 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,184 @@ 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; - - @Captor private ArgumentCaptor> eventCaptor; - - private AutoCloseable mocks; - - @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()); - } - - @After - public void after() throws Exception { - mocks.close(); - } - - @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()); - } - - @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); - } - - @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); - } - - private Player.Listener initVideoPlayerAndGetListener() { - ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(Player.Listener.class); - doNothing().when(fakeExoPlayer).addListener(listenerCaptor.capture()); - - // 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()); - } - - @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(); - - Map expected = new HashMap<>(); - expected.put("event", "bufferingUpdate"); - - List range = Arrays.asList(0, 10L); - expected.put("values", Collections.singletonList(range)); - - // 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; - } +public final class VideoPlayerTest { + private static final String FAKE_ASSET_URL = "https://flutter.dev/movie.mp4"; + private FakeVideoAsset fakeVideoAsset; + + @Mock + private VideoPlayerCallbacks mockEvents; + @Mock + private TextureRegistry.SurfaceTextureEntry mockTexture; + @Mock + private ExoPlayer.Builder mockBuilder; + @Mock + private ExoPlayer mockExoPlayer; + @Captor + private ArgumentCaptor attributesCaptor; + + @Rule + public MockitoRule initRule = MockitoJUnit.rule(); + + @Before + public void setUp() { + fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL); + when(mockBuilder.build()).thenReturn(mockExoPlayer); + when(mockTexture.surfaceTexture()).thenReturn(mock(SurfaceTexture.class)); } - fail("No 'bufferingUpdate' event found: " + events); - } + private VideoPlayer createVideoPlayer() { + return createVideoPlayer(new VideoPlayerOptions()); + } + + private VideoPlayer createVideoPlayer(VideoPlayerOptions options) { + return new VideoPlayer(mockBuilder, mockEvents, mockTexture, fakeVideoAsset.getMediaItem(), options); + } + + @Test + public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() { + VideoPlayer videoPlayer = createVideoPlayer(); + + verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem()); + verify(mockExoPlayer).prepare(); + verify(mockTexture).surfaceTexture(); + verify(mockExoPlayer).setVideoSurface(any()); + + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true)); + assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); + + videoPlayer.dispose(); + } - @Test - public void sendInitializedSendsExpectedEvent_90RotationDegrees() { - Player.Listener listener = initVideoPlayerAndGetListener(); - VideoSize testVideoSize = new VideoSize(100, 200, 90, 1f); + @Test + public void loadsAndPreparesProvidedMediaDisablesAudioFocusWhenMixModeSet() { + VideoPlayerOptions options = new VideoPlayerOptions(); + options.mixWithOthers = true; - when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); - when(fakeExoPlayer.getDuration()).thenReturn(10L); - - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); - - 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 videoPlayer = createVideoPlayer(options); - @Test - public void sendInitializedSendsExpectedEvent_270RotationDegrees() { - Player.Listener listener = initVideoPlayerAndGetListener(); - VideoSize testVideoSize = new VideoSize(100, 200, 270, 1f); + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(false)); + assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); - when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); - when(fakeExoPlayer.getDuration()).thenReturn(10L); + videoPlayer.dispose(); + } + + @Test + public void playsAndPausesProvidedMedia() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.play(); + verify(mockExoPlayer).setPlayWhenReady(true); + + videoPlayer.pause(); + verify(mockExoPlayer).setPlayWhenReady(false); + + videoPlayer.dispose(); + } + + @Test + public void sendsBufferingUpdatesOnDemand() { + VideoPlayer videoPlayer = createVideoPlayer(); + + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); + videoPlayer.sendBufferingUpdate(); + verify(mockEvents).onBufferingUpdate(10L); + + videoPlayer.dispose(); + } - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); + @Test + public void togglesLoopingEnablesAndDisablesRepeatMode() { + VideoPlayer videoPlayer = createVideoPlayer(); - 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); - } - - @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); - - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); - - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap actual = eventCaptor.getValue(); - - Map expected = new HashMap<>(); - expected.put("event", "initialized"); - expected.put("duration", 10L); - expected.put("width", 100); - expected.put("height", 200); - - assertEquals(expected, actual); - } - - @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); - - // Send Player.STATE_READY to trigger the "initialized" event. - listener.onPlaybackStateChanged(Player.STATE_READY); - - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap actual = eventCaptor.getValue(); - - Map expected = new HashMap<>(); - expected.put("event", "initialized"); - expected.put("duration", 10L); - expected.put("width", 100); - expected.put("height", 200); - expected.put("rotationCorrection", 180); - - assertEquals(expected, actual); - } - - @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(); - - verify(fakeEventSink).success(eventCaptor.capture()); - HashMap event1 = eventCaptor.getValue(); - - assertEquals(event1.get("event"), "isPlayingStateUpdate"); - assertEquals(event1.get("isPlaying"), true); - - videoPlayer.pause(); - - verify(fakeEventSink, times(2)).success(eventCaptor.capture()); - HashMap event2 = eventCaptor.getValue(); - - assertEquals(event2.get("event"), "isPlayingStateUpdate"); - assertEquals(event2.get("isPlaying"), false); - } - - @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(); - } - - @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()); - } + videoPlayer.setLooping(true); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_ALL); + + videoPlayer.setLooping(false); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_OFF); + + videoPlayer.dispose(); + } + + @Test + public void setVolumeIsClampedBetween0and1() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.setVolume(-1.0); + verify(mockExoPlayer).setVolume(0f); + + videoPlayer.setVolume(2.0); + verify(mockExoPlayer).setVolume(1f); + + videoPlayer.setVolume(0.5); + verify(mockExoPlayer).setVolume(0.5f); + + videoPlayer.dispose(); + } + + @Test + public void setPlaybackSpeedSetsPlaybackParametersWithValue() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.setPlaybackSpeed(2.5); + verify(mockExoPlayer).setPlaybackParameters(new PlaybackParameters(2.5f)); + + videoPlayer.dispose(); + } + + @Test + public void seekAndGetPosition() { + VideoPlayer videoPlayer = createVideoPlayer(); + + videoPlayer.seekTo(10); + verify(mockExoPlayer).seekTo(10); + + when(mockExoPlayer.getCurrentPosition()).thenReturn(20L); + assertEquals(20L, videoPlayer.getPosition()); + } + + @Test + public void disposeReleasesTextureAndPlayer() { + VideoPlayer videoPlayer = createVideoPlayer(); + videoPlayer.dispose();; + + verify(mockTexture).release(); + verify(mockExoPlayer).release(); + } } From 892115ed03f756e4cabf2f6785f8788a5b7ac007 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Mon, 24 Jun 2024 21:35:58 -0700 Subject: [PATCH 02/14] ++ for draft review. --- .../video_player/video_player_android/android/build.gradle | 4 ---- .../video_player/video_player_android/example/pubspec.yaml | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle index e81de638d55..77f504de422 100644 --- a/packages/video_player/video_player_android/android/build.gradle +++ b/packages/video_player/video_player_android/android/build.gradle @@ -71,7 +71,3 @@ android { } } } - -dependencies { - implementation 'androidx.media3:media3-test-utils:1.3.1' -} diff --git a/packages/video_player/video_player_android/example/pubspec.yaml b/packages/video_player/video_player_android/example/pubspec.yaml index a6c57dc8d8b..03d6386bbc4 100644 --- a/packages/video_player/video_player_android/example/pubspec.yaml +++ b/packages/video_player/video_player_android/example/pubspec.yaml @@ -31,3 +31,7 @@ flutter: assets: - assets/flutter-mark-square-64.png - assets/Butterfly-209.mp4 + +# FIXME: Remove this override before submitting the example. +dependency_overrides: + win32: 5.5.1 From deb7d4b5812b6f666e95fe2ab6c17b6bc64387d8 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Mon, 24 Jun 2024 21:41:18 -0700 Subject: [PATCH 03/14] Javafmt. --- .../plugins/videoplayer/VideoAsset.java | 334 +++++++------- .../plugins/videoplayer/VideoPlayer.java | 12 +- .../videoplayer/VideoPlayerPlugin.java | 408 +++++++++--------- .../ExoPlayerEventListenerTests.java | 288 ++++++------- .../plugins/videoplayer/FakeVideoAsset.java | 70 ++- .../plugins/videoplayer/VideoAssetTest.java | 162 ++++--- .../VideoPlayerEventCallbacksTest.java | 184 ++++---- .../plugins/videoplayer/VideoPlayerTest.java | 223 +++++----- 8 files changed, 805 insertions(+), 876 deletions(-) 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 index a48080aa999..e8431f2ccec 100644 --- 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 @@ -1,7 +1,6 @@ package io.flutter.plugins.videoplayer; import android.content.Context; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.OptIn; @@ -14,197 +13,180 @@ import androidx.media3.datasource.DefaultHttpDataSource; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaSource; - import java.util.HashMap; import java.util.Map; -/** - * A video to be played by {@link VideoPlayer}. - */ +/** 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 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 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 + abstract MediaSource.Factory getMediaSourceFactory( + Context context, DefaultHttpDataSource.Factory initialFactory); + + /** + * 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); + + private static final class LocalVideoAsset extends VideoAsset { + private LocalVideoAsset(@NonNull String assetUrl) { + super(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)); + @Override + MediaItem getMediaItem() { + return new MediaItem.Builder().setUri(assetUrl).build(); } - @Nullable - protected final String assetUrl; + @Override + MediaSource.Factory getMediaSourceFactory(Context context) { + return new DefaultMediaSourceFactory(context); + } - protected VideoAsset(@Nullable String assetUrl) { - this.assetUrl = assetUrl; + @Override + MediaSource.Factory getMediaSourceFactory( + Context context, DefaultHttpDataSource.Factory initialFactory) { + return new DefaultMediaSourceFactory(context); + } + } + + private static 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; + + private RemoteVideoAsset( + @Nullable String assetUrl, + @NonNull StreamingFormat streamingFormat, + @NonNull Map httpHeaders) { + super(assetUrl); + this.streamingFormat = streamingFormat; + this.httpHeaders = httpHeaders; } - /** - * Returns the configured media item to be played. - * - * @return media item. - */ @NonNull - abstract MediaItem getMediaItem(); - - /** - * 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 - abstract MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory initialFactory); - - /** - * 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); - - private static final class LocalVideoAsset extends VideoAsset { - private 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); - } - - @Override - MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory initialFactory) { - return new DefaultMediaSourceFactory(context); - } + @Override + MediaItem getMediaItem() { + MediaItem.Builder builder = new MediaItem.Builder().setUri(assetUrl); + String mimeType = null; + switch (streamingFormat) { + case Smooth: + mimeType = MimeTypes.APPLICATION_SS; + break; + case DynamicAdaptive: + mimeType = MimeTypes.APPLICATION_MPD; + break; + case HttpLive: + mimeType = MimeTypes.APPLICATION_M3U8; + break; + } + if (mimeType != null) { + builder.setMimeType(mimeType); + } + return builder.build(); } - private static 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; - - private 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 DynamicAdaptive: - mimeType = MimeTypes.APPLICATION_MPD; - break; - case HttpLive: - 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()); - } - - @Override - MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory factory) { - String userAgent = DEFAULT_USER_AGENT; - if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) { - userAgent = httpHeaders.get(HEADER_USER_AGENT); - } - unstableUpdateDataSourceFactory(factory, httpHeaders, userAgent); - - DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, factory); - 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); - } - } + @Override + MediaSource.Factory getMediaSourceFactory(Context context) { + return getMediaSourceFactory(context, new DefaultHttpDataSource.Factory()); } - /** - * 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). - */ - DynamicAdaptive, - - /** - * HTTP Live Streaming (HLS). - */ - HttpLive + @Override + MediaSource.Factory getMediaSourceFactory( + Context context, DefaultHttpDataSource.Factory factory) { + String userAgent = DEFAULT_USER_AGENT; + if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) { + userAgent = httpHeaders.get(HEADER_USER_AGENT); + } + unstableUpdateDataSourceFactory(factory, httpHeaders, userAgent); + + DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, factory); + 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); + } + } + } + + /** 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). */ + DynamicAdaptive, + + /** HTTP Live Streaming (HLS). */ + HttpLive + } } 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 21d156e4c04..ab20b30f42d 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 @@ -15,10 +15,7 @@ import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.PlaybackParameters; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DefaultDataSource; import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import io.flutter.view.TextureRegistry; final class VideoPlayer { @@ -28,7 +25,6 @@ final class VideoPlayer { private final VideoPlayerCallbacks videoPlayerEvents; private final VideoPlayerOptions options; - /** * Creates a video player. * @@ -37,17 +33,17 @@ final class VideoPlayer { * @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 ( + static VideoPlayer create( Context context, VideoPlayerCallbacks events, TextureRegistry.SurfaceTextureEntry textureEntry, VideoAsset asset, VideoPlayerOptions options) { - ExoPlayer.Builder builder = new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context)); + ExoPlayer.Builder builder = + new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context)); return new VideoPlayer(builder, events, textureEntry, asset.getMediaItem(), options); } @@ -58,7 +54,7 @@ static VideoPlayer create ( TextureRegistry.SurfaceTextureEntry textureEntry, MediaItem mediaItem, VideoPlayerOptions options) { - this.videoPlayerEvents = events; + this.videoPlayerEvents = events; this.textureEntry = textureEntry; this.options = options; 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 1092126c741..1fc69eddf6b 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 @@ -7,9 +7,7 @@ import android.content.Context; import android.os.Build; import android.util.LongSparseArray; - import androidx.annotation.NonNull; - import io.flutter.FlutterInjector; import io.flutter.Log; import io.flutter.embedding.engine.plugins.FlutterPlugin; @@ -24,217 +22,211 @@ import io.flutter.plugins.videoplayer.Messages.TextureMessage; import io.flutter.plugins.videoplayer.Messages.VolumeMessage; 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; -/** - * Android platform implementation of the VideoPlayerPlugin. - */ +/** Android platform implementation of the VideoPlayerPlugin. */ public class VideoPlayerPlugin implements FlutterPlugin, AndroidVideoPlayerApi { - private static final String TAG = "VideoPlayerPlugin"; - private final LongSparseArray videoPlayers = new LongSparseArray<>(); - private FlutterState flutterState; - private final VideoPlayerOptions options = new VideoPlayerOptions(); - - /** - * Register this with the v2 embedding for the plugin to respond to lifecycle callbacks. - */ - public VideoPlayerPlugin() { - } - - @Override - public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { - if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - try { - HttpsURLConnection.setDefaultSSLSocketFactory(new CustomSSLSocketFactory()); - } catch (KeyManagementException | NoSuchAlgorithmException e) { - Log.w( - TAG, - "Failed to enable TLSv1.1 and TLSv1.2 Protocols for API level 19 and below.\n" - + "For more information about Socket Security, please consult the following link:\n" - + "https://developer.android.com/reference/javax/net/ssl/SSLSocket", - e); - } - } - - final FlutterInjector injector = FlutterInjector.instance(); - this.flutterState = - new FlutterState( - binding.getApplicationContext(), - binding.getBinaryMessenger(), - injector.flutterLoader()::getLookupKeyForAsset, - injector.flutterLoader()::getLookupKeyForAsset, - binding.getTextureRegistry()); - flutterState.startListening(this, binding.getBinaryMessenger()); - } - - @Override - public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { - if (flutterState == null) { - Log.wtf(TAG, "Detached from the engine before registering to it."); - } - flutterState.stopListening(binding.getBinaryMessenger()); - flutterState = null; - onDestroy(); - } - - private void disposeAllPlayers() { - for (int i = 0; i < videoPlayers.size(); i++) { - videoPlayers.valueAt(i).dispose(); - } - videoPlayers.clear(); - } - - public void onDestroy() { - // The whole FlutterView is being destroyed. Here we release resources acquired for all - // instances - // of VideoPlayer. Once https://github.com/flutter/flutter/issues/19358 is resolved this may - // be replaced with just asserting that videoPlayers.isEmpty(). - // https://github.com/flutter/flutter/issues/20989 tracks this. - disposeAllPlayers(); - } - - public void initialize() { - disposeAllPlayers(); - } - - public @NonNull TextureMessage create(@NonNull CreateMessage arg) { - TextureRegistry.SurfaceTextureEntry handle = - flutterState.textureRegistry.createSurfaceTexture(); - EventChannel eventChannel = - new EventChannel( - flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + handle.id()); - - final VideoAsset videoAsset; - if (arg.getAsset() != null) { - String assetLookupKey; - if (arg.getPackageName() != null) { - assetLookupKey = - flutterState.keyForAssetAndPackageName.get(arg.getAsset(), arg.getPackageName()); - } else { - assetLookupKey = flutterState.keyForAsset.get(arg.getAsset()); - } - videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey); - } else { - Map httpHeaders = arg.getHttpHeaders(); - 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.DynamicAdaptive; - break; - case "hls": - streamingFormat = VideoAsset.StreamingFormat.HttpLive; - break; - } - } - videoAsset = VideoAsset.fromRemoteUrl(arg.getUri(), streamingFormat, arg.getHttpHeaders()); - } - videoPlayers.put(handle.id(), VideoPlayer.create( - flutterState.applicationContext, - VideoPlayerEventCallbacks.bindTo(eventChannel), - handle, - videoAsset, - options)); - - return new TextureMessage.Builder().setTextureId(handle.id()).build(); - } - - public void dispose(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.dispose(); - videoPlayers.remove(arg.getTextureId()); - } - - public void setLooping(@NonNull LoopingMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.setLooping(arg.getIsLooping()); - } - - public void setVolume(@NonNull VolumeMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.setVolume(arg.getVolume()); - } - - public void setPlaybackSpeed(@NonNull PlaybackSpeedMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.setPlaybackSpeed(arg.getSpeed()); - } - - public void play(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.play(); - } - - public @NonNull PositionMessage position(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - PositionMessage result = - new PositionMessage.Builder() - .setPosition(player.getPosition()) - .setTextureId(arg.getTextureId()) - .build(); - player.sendBufferingUpdate(); - return result; - } - - public void seekTo(@NonNull PositionMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.seekTo(arg.getPosition().intValue()); - } - - public void pause(@NonNull TextureMessage arg) { - VideoPlayer player = videoPlayers.get(arg.getTextureId()); - player.pause(); - } - - @Override - public void setMixWithOthers(@NonNull MixWithOthersMessage arg) { - options.mixWithOthers = arg.getMixWithOthers(); - } - - private interface KeyForAssetFn { - String get(String asset); - } - - private interface KeyForAssetAndPackageName { - String get(String asset, String packageName); - } - - private static final class FlutterState { - final Context applicationContext; - final BinaryMessenger binaryMessenger; - final KeyForAssetFn keyForAsset; - final KeyForAssetAndPackageName keyForAssetAndPackageName; - final TextureRegistry textureRegistry; - - FlutterState( - Context applicationContext, - BinaryMessenger messenger, - KeyForAssetFn keyForAsset, - KeyForAssetAndPackageName keyForAssetAndPackageName, - TextureRegistry textureRegistry) { - this.applicationContext = applicationContext; - this.binaryMessenger = messenger; - this.keyForAsset = keyForAsset; - this.keyForAssetAndPackageName = keyForAssetAndPackageName; - this.textureRegistry = textureRegistry; - } - - void startListening(VideoPlayerPlugin methodCallHandler, BinaryMessenger messenger) { - AndroidVideoPlayerApi.setup(messenger, methodCallHandler); + private static final String TAG = "VideoPlayerPlugin"; + private final LongSparseArray videoPlayers = new LongSparseArray<>(); + private FlutterState flutterState; + private final VideoPlayerOptions options = new VideoPlayerOptions(); + + /** Register this with the v2 embedding for the plugin to respond to lifecycle callbacks. */ + public VideoPlayerPlugin() {} + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + try { + HttpsURLConnection.setDefaultSSLSocketFactory(new CustomSSLSocketFactory()); + } catch (KeyManagementException | NoSuchAlgorithmException e) { + Log.w( + TAG, + "Failed to enable TLSv1.1 and TLSv1.2 Protocols for API level 19 and below.\n" + + "For more information about Socket Security, please consult the following link:\n" + + "https://developer.android.com/reference/javax/net/ssl/SSLSocket", + e); + } + } + + final FlutterInjector injector = FlutterInjector.instance(); + this.flutterState = + new FlutterState( + binding.getApplicationContext(), + binding.getBinaryMessenger(), + injector.flutterLoader()::getLookupKeyForAsset, + injector.flutterLoader()::getLookupKeyForAsset, + binding.getTextureRegistry()); + flutterState.startListening(this, binding.getBinaryMessenger()); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + if (flutterState == null) { + Log.wtf(TAG, "Detached from the engine before registering to it."); + } + flutterState.stopListening(binding.getBinaryMessenger()); + flutterState = null; + onDestroy(); + } + + private void disposeAllPlayers() { + for (int i = 0; i < videoPlayers.size(); i++) { + videoPlayers.valueAt(i).dispose(); + } + videoPlayers.clear(); + } + + public void onDestroy() { + // The whole FlutterView is being destroyed. Here we release resources acquired for all + // instances + // of VideoPlayer. Once https://github.com/flutter/flutter/issues/19358 is resolved this may + // be replaced with just asserting that videoPlayers.isEmpty(). + // https://github.com/flutter/flutter/issues/20989 tracks this. + disposeAllPlayers(); + } + + public void initialize() { + disposeAllPlayers(); + } + + public @NonNull TextureMessage create(@NonNull CreateMessage arg) { + TextureRegistry.SurfaceTextureEntry handle = + flutterState.textureRegistry.createSurfaceTexture(); + EventChannel eventChannel = + new EventChannel( + flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + handle.id()); + + final VideoAsset videoAsset; + if (arg.getAsset() != null) { + String assetLookupKey; + if (arg.getPackageName() != null) { + assetLookupKey = + flutterState.keyForAssetAndPackageName.get(arg.getAsset(), arg.getPackageName()); + } else { + assetLookupKey = flutterState.keyForAsset.get(arg.getAsset()); + } + videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey); + } else { + Map httpHeaders = arg.getHttpHeaders(); + 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.DynamicAdaptive; + break; + case "hls": + streamingFormat = VideoAsset.StreamingFormat.HttpLive; + break; } - - void stopListening(BinaryMessenger messenger) { - AndroidVideoPlayerApi.setup(messenger, null); - } - } + } + videoAsset = VideoAsset.fromRemoteUrl(arg.getUri(), streamingFormat, arg.getHttpHeaders()); + } + videoPlayers.put( + handle.id(), + VideoPlayer.create( + flutterState.applicationContext, + VideoPlayerEventCallbacks.bindTo(eventChannel), + handle, + videoAsset, + options)); + + return new TextureMessage.Builder().setTextureId(handle.id()).build(); + } + + public void dispose(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.dispose(); + videoPlayers.remove(arg.getTextureId()); + } + + public void setLooping(@NonNull LoopingMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.setLooping(arg.getIsLooping()); + } + + public void setVolume(@NonNull VolumeMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.setVolume(arg.getVolume()); + } + + public void setPlaybackSpeed(@NonNull PlaybackSpeedMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.setPlaybackSpeed(arg.getSpeed()); + } + + public void play(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.play(); + } + + public @NonNull PositionMessage position(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + PositionMessage result = + new PositionMessage.Builder() + .setPosition(player.getPosition()) + .setTextureId(arg.getTextureId()) + .build(); + player.sendBufferingUpdate(); + return result; + } + + public void seekTo(@NonNull PositionMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.seekTo(arg.getPosition().intValue()); + } + + public void pause(@NonNull TextureMessage arg) { + VideoPlayer player = videoPlayers.get(arg.getTextureId()); + player.pause(); + } + + @Override + public void setMixWithOthers(@NonNull MixWithOthersMessage arg) { + options.mixWithOthers = arg.getMixWithOthers(); + } + + private interface KeyForAssetFn { + String get(String asset); + } + + private interface KeyForAssetAndPackageName { + String get(String asset, String packageName); + } + + private static final class FlutterState { + final Context applicationContext; + final BinaryMessenger binaryMessenger; + final KeyForAssetFn keyForAsset; + final KeyForAssetAndPackageName keyForAssetAndPackageName; + final TextureRegistry textureRegistry; + + FlutterState( + Context applicationContext, + BinaryMessenger messenger, + KeyForAssetFn keyForAsset, + KeyForAssetAndPackageName keyForAssetAndPackageName, + TextureRegistry textureRegistry) { + this.applicationContext = applicationContext; + this.binaryMessenger = messenger; + this.keyForAsset = keyForAsset; + this.keyForAssetAndPackageName = keyForAssetAndPackageName; + this.textureRegistry = textureRegistry; + } + + void startListening(VideoPlayerPlugin methodCallHandler, BinaryMessenger messenger) { + AndroidVideoPlayerApi.setup(messenger, methodCallHandler); + } + + void stopListening(BinaryMessenger messenger) { + AndroidVideoPlayerApi.setup(messenger, null); + } + } } 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 index 392feb0febe..6af16024e5f 100644 --- 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 @@ -1,11 +1,8 @@ package io.flutter.plugins.videoplayer; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -15,7 +12,6 @@ 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; @@ -25,153 +21,151 @@ 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. + *

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); - } + @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 index 9741e47bb5b..7a8f1d58639 100644 --- 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 @@ -1,55 +1,41 @@ package io.flutter.plugins.videoplayer; import android.content.Context; - import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.media3.common.AdPlaybackState; -import androidx.media3.common.Format; import androidx.media3.common.MediaItem; import androidx.media3.datasource.DefaultHttpDataSource; -import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; -import androidx.media3.test.utils.FakeMediaSource; import androidx.media3.test.utils.FakeMediaSourceFactory; -import androidx.media3.test.utils.FakeTimeline; -import androidx.media3.test.utils.FakeTrackOutput; -import com.google.common.collect.Lists; -import java.time.Duration; -import java.util.Collections; -/** - * A fake implementation of the {@link VideoAsset} class. - */ +/** 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; - } - - @Override - MediaSource.Factory getMediaSourceFactory(Context context, DefaultHttpDataSource.Factory initialFactory) { - return getMediaSourceFactory(context); - } + @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; + } + + @Override + MediaSource.Factory getMediaSourceFactory( + Context context, DefaultHttpDataSource.Factory initialFactory) { + return getMediaSourceFactory(context); + } } 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 index 51700eb6faa..444f1d3914a 100644 --- 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 @@ -11,105 +11,95 @@ import static org.mockito.Mockito.when; import android.net.Uri; - import androidx.media3.common.MediaItem; import androidx.media3.datasource.DefaultHttpDataSource; import androidx.test.core.app.ApplicationProvider; - +import java.util.HashMap; +import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; -import java.util.HashMap; -import java.util.Map; - /** * 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. + *

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() throws Exception { - VideoAsset asset = VideoAsset.fromRemoteUrl( - "https://flutter.dev/video.mp4", - VideoAsset.StreamingFormat.Unknown, - new HashMap<>()); - - DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); - asset.getMediaSourceFactory( - ApplicationProvider.getApplicationContext(), - mockFactory); - - verify(mockFactory).setUserAgent("ExoPlayer"); - verify(mockFactory).setAllowCrossProtocolRedirects(true); - verify(mockFactory, never()).setDefaultRequestProperties(anyMap()); - } - - @Test - public void remoteVideoOverridesUserAgentIfProvided() throws Exception { - 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(); - asset.getMediaSourceFactory( - ApplicationProvider.getApplicationContext(), - mockFactory); - - verify(mockFactory).setUserAgent("FantasticalVideoBot"); - verify(mockFactory).setAllowCrossProtocolRedirects(true); - verify(mockFactory).setDefaultRequestProperties(headers); - } - - @Test - public void remoteVideoSetsAdditionalHttpHeadersIfProvided() throws Exception { - 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(); - asset.getMediaSourceFactory( - ApplicationProvider.getApplicationContext(), - mockFactory); - - verify(mockFactory).setUserAgent("ExoPlayer"); - verify(mockFactory).setAllowCrossProtocolRedirects(true); - verify(mockFactory).setDefaultRequestProperties(headers); - } + @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() throws Exception { + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.Unknown, new HashMap<>()); + + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); + asset.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + verify(mockFactory).setUserAgent("ExoPlayer"); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory, never()).setDefaultRequestProperties(anyMap()); + } + + @Test + public void remoteVideoOverridesUserAgentIfProvided() throws Exception { + 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(); + asset.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + verify(mockFactory).setUserAgent("FantasticalVideoBot"); + verify(mockFactory).setAllowCrossProtocolRedirects(true); + verify(mockFactory).setDefaultRequestProperties(headers); + } + + @Test + public void remoteVideoSetsAdditionalHttpHeadersIfProvided() throws Exception { + 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(); + 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 index 4809c7c3872..60b355fa8dd 100644 --- 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 @@ -4,6 +4,10 @@ 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; @@ -15,11 +19,6 @@ import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - /** * Unit tests {@link VideoPlayerEventCallbacks}. * @@ -31,121 +30,118 @@ */ @RunWith(RobolectricTestRunner.class) public final class VideoPlayerEventCallbacksTest { - private VideoPlayerEventCallbacks eventCallbacks; + private VideoPlayerEventCallbacks eventCallbacks; + + @Mock private QueuingEventSink mockEventSink; - @Mock - private QueuingEventSink mockEventSink; + @Captor private ArgumentCaptor> eventCaptor; - @Captor - private ArgumentCaptor> eventCaptor; + @Rule public MockitoRule initRule = MockitoJUnit.rule(); - @Rule - public MockitoRule initRule = MockitoJUnit.rule(); + @Before + public void setUp() { + eventCallbacks = VideoPlayerEventCallbacks.withSink(mockEventSink); + } - @Before - public void setUp() { - eventCallbacks = VideoPlayerEventCallbacks.withSink(mockEventSink); - } + @Test + public void onInitializedSendsWidthHeightAndDuration() { + eventCallbacks.onInitialized(800, 400, 10L, 0); - @Test - public void onInitializedSendsWidthHeightAndDuration() { - eventCallbacks.onInitialized(800, 400, 10L, 0); + verify(mockEventSink).success(eventCaptor.capture()); - 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); - 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); + } - assertEquals(expected, actual); - } + @Test + public void onInitializedIncludesRotationCorrectIfNonZero() { + eventCallbacks.onInitialized(800, 400, 10L, 180); - @Test - public void onInitializedIncludesRotationCorrectIfNonZero() { - eventCallbacks.onInitialized(800, 400, 10L, 180); + verify(mockEventSink).success(eventCaptor.capture()); - 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); - 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); + } - assertEquals(expected, actual); - } + @Test + public void onBufferingStart() { + eventCallbacks.onBufferingStart(); - @Test - public void onBufferingStart() { - eventCallbacks.onBufferingStart(); + verify(mockEventSink).success(eventCaptor.capture()); - verify(mockEventSink).success(eventCaptor.capture()); + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "bufferingStart"); + assertEquals(expected, actual); + } - Map actual = eventCaptor.getValue(); - Map expected = new HashMap<>(); - expected.put("event", "bufferingStart"); - assertEquals(expected, actual); - } + @Test + public void onBufferingUpdateProvidesAListWithASingleRange() { + eventCallbacks.onBufferingUpdate(10L); - @Test - public void onBufferingUpdateProvidesAListWithASingleRange() { - eventCallbacks.onBufferingUpdate(10L); + verify(mockEventSink).success(eventCaptor.capture()); - 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); + } - 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(); - @Test - public void onBufferingEnd() { - eventCallbacks.onBufferingEnd(); + verify(mockEventSink).success(eventCaptor.capture()); - verify(mockEventSink).success(eventCaptor.capture()); + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "bufferingEnd"); + assertEquals(expected, actual); + } - Map actual = eventCaptor.getValue(); - Map expected = new HashMap<>(); - expected.put("event", "bufferingEnd"); - assertEquals(expected, actual); - } + @Test + public void onCompleted() { + eventCallbacks.onCompleted(); - @Test - public void onCompleted() { - eventCallbacks.onCompleted(); + verify(mockEventSink).success(eventCaptor.capture()); - verify(mockEventSink).success(eventCaptor.capture()); + Map actual = eventCaptor.getValue(); + Map expected = new HashMap<>(); + expected.put("event", "completed"); + assertEquals(expected, actual); + } - Map actual = eventCaptor.getValue(); - Map expected = new HashMap<>(); - expected.put("event", "completed"); - assertEquals(expected, actual); - } + @Test + public void onError() { + eventCallbacks.onError("code", "message", "details"); - @Test - public void onError() { - eventCallbacks.onError("code", "message", "details"); + verify(mockEventSink).error(eq("code"), eq("message"), eq("details")); + } - verify(mockEventSink).error(eq("code"), eq("message"), eq("details")); - } + @Test + public void onIsPlayingStateUpdate() { + eventCallbacks.onIsPlayingStateUpdate(true); - @Test - public void onIsPlayingStateUpdate() { - eventCallbacks.onIsPlayingStateUpdate(true); - - verify(mockEventSink).success(eventCaptor.capture()); + verify(mockEventSink).success(eventCaptor.capture()); - Map actual = eventCaptor.getValue(); - Map expected = new HashMap<>(); - expected.put("event", "isPlayingStateUpdate"); - expected.put("isPlaying", true); - assertEquals(expected, actual); - } + 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 381b168b478..4a936996a58 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 @@ -9,15 +9,12 @@ import static org.mockito.Mockito.*; import android.graphics.SurfaceTexture; - import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; import androidx.media3.common.PlaybackParameters; import androidx.media3.common.Player; import androidx.media3.exoplayer.ExoPlayer; - import io.flutter.view.TextureRegistry; - import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -32,157 +29,153 @@ /** * 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". + *

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". + *

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 final class VideoPlayerTest { - private static final String FAKE_ASSET_URL = "https://flutter.dev/movie.mp4"; - private FakeVideoAsset fakeVideoAsset; + private static final String FAKE_ASSET_URL = "https://flutter.dev/movie.mp4"; + private FakeVideoAsset fakeVideoAsset; - @Mock - private VideoPlayerCallbacks mockEvents; - @Mock - private TextureRegistry.SurfaceTextureEntry mockTexture; - @Mock - private ExoPlayer.Builder mockBuilder; - @Mock - private ExoPlayer mockExoPlayer; - @Captor - private ArgumentCaptor attributesCaptor; + @Mock private VideoPlayerCallbacks mockEvents; + @Mock private TextureRegistry.SurfaceTextureEntry mockTexture; + @Mock private ExoPlayer.Builder mockBuilder; + @Mock private ExoPlayer mockExoPlayer; + @Captor private ArgumentCaptor attributesCaptor; - @Rule - public MockitoRule initRule = MockitoJUnit.rule(); + @Rule public MockitoRule initRule = MockitoJUnit.rule(); - @Before - public void setUp() { - fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL); - when(mockBuilder.build()).thenReturn(mockExoPlayer); - when(mockTexture.surfaceTexture()).thenReturn(mock(SurfaceTexture.class)); - } + @Before + public void setUp() { + fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL); + when(mockBuilder.build()).thenReturn(mockExoPlayer); + when(mockTexture.surfaceTexture()).thenReturn(mock(SurfaceTexture.class)); + } - private VideoPlayer createVideoPlayer() { - return createVideoPlayer(new VideoPlayerOptions()); - } + private VideoPlayer createVideoPlayer() { + return createVideoPlayer(new VideoPlayerOptions()); + } - private VideoPlayer createVideoPlayer(VideoPlayerOptions options) { - return new VideoPlayer(mockBuilder, mockEvents, mockTexture, fakeVideoAsset.getMediaItem(), options); - } + private VideoPlayer createVideoPlayer(VideoPlayerOptions options) { + return new VideoPlayer( + mockBuilder, mockEvents, mockTexture, fakeVideoAsset.getMediaItem(), options); + } - @Test - public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() { - VideoPlayer videoPlayer = createVideoPlayer(); + @Test + public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() { + VideoPlayer videoPlayer = createVideoPlayer(); - verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem()); - verify(mockExoPlayer).prepare(); - verify(mockTexture).surfaceTexture(); - verify(mockExoPlayer).setVideoSurface(any()); + verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem()); + verify(mockExoPlayer).prepare(); + verify(mockTexture).surfaceTexture(); + verify(mockExoPlayer).setVideoSurface(any()); - verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true)); - assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true)); + assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); - videoPlayer.dispose(); - } + videoPlayer.dispose(); + } - @Test - public void loadsAndPreparesProvidedMediaDisablesAudioFocusWhenMixModeSet() { - VideoPlayerOptions options = new VideoPlayerOptions(); - options.mixWithOthers = true; + @Test + public void loadsAndPreparesProvidedMediaDisablesAudioFocusWhenMixModeSet() { + VideoPlayerOptions options = new VideoPlayerOptions(); + options.mixWithOthers = true; - VideoPlayer videoPlayer = createVideoPlayer(options); + VideoPlayer videoPlayer = createVideoPlayer(options); - verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(false)); - assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(false)); + assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE); - videoPlayer.dispose(); - } + videoPlayer.dispose(); + } - @Test - public void playsAndPausesProvidedMedia() { - VideoPlayer videoPlayer = createVideoPlayer(); + @Test + public void playsAndPausesProvidedMedia() { + VideoPlayer videoPlayer = createVideoPlayer(); - videoPlayer.play(); - verify(mockExoPlayer).setPlayWhenReady(true); + videoPlayer.play(); + verify(mockExoPlayer).setPlayWhenReady(true); - videoPlayer.pause(); - verify(mockExoPlayer).setPlayWhenReady(false); + videoPlayer.pause(); + verify(mockExoPlayer).setPlayWhenReady(false); - videoPlayer.dispose(); - } + videoPlayer.dispose(); + } - @Test - public void sendsBufferingUpdatesOnDemand() { - VideoPlayer videoPlayer = createVideoPlayer(); + @Test + public void sendsBufferingUpdatesOnDemand() { + VideoPlayer videoPlayer = createVideoPlayer(); - when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); - videoPlayer.sendBufferingUpdate(); - verify(mockEvents).onBufferingUpdate(10L); + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L); + videoPlayer.sendBufferingUpdate(); + verify(mockEvents).onBufferingUpdate(10L); - videoPlayer.dispose(); - } + videoPlayer.dispose(); + } - @Test - public void togglesLoopingEnablesAndDisablesRepeatMode() { - VideoPlayer videoPlayer = createVideoPlayer(); + @Test + public void togglesLoopingEnablesAndDisablesRepeatMode() { + VideoPlayer videoPlayer = createVideoPlayer(); - videoPlayer.setLooping(true); - verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_ALL); + videoPlayer.setLooping(true); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_ALL); - videoPlayer.setLooping(false); - verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_OFF); + videoPlayer.setLooping(false); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_OFF); - videoPlayer.dispose(); - } + videoPlayer.dispose(); + } - @Test - public void setVolumeIsClampedBetween0and1() { - VideoPlayer videoPlayer = createVideoPlayer(); + @Test + public void setVolumeIsClampedBetween0and1() { + VideoPlayer videoPlayer = createVideoPlayer(); - videoPlayer.setVolume(-1.0); - verify(mockExoPlayer).setVolume(0f); + videoPlayer.setVolume(-1.0); + verify(mockExoPlayer).setVolume(0f); - videoPlayer.setVolume(2.0); - verify(mockExoPlayer).setVolume(1f); + videoPlayer.setVolume(2.0); + verify(mockExoPlayer).setVolume(1f); - videoPlayer.setVolume(0.5); - verify(mockExoPlayer).setVolume(0.5f); + videoPlayer.setVolume(0.5); + verify(mockExoPlayer).setVolume(0.5f); - videoPlayer.dispose(); - } + videoPlayer.dispose(); + } - @Test - public void setPlaybackSpeedSetsPlaybackParametersWithValue() { - VideoPlayer videoPlayer = createVideoPlayer(); + @Test + public void setPlaybackSpeedSetsPlaybackParametersWithValue() { + VideoPlayer videoPlayer = createVideoPlayer(); - videoPlayer.setPlaybackSpeed(2.5); - verify(mockExoPlayer).setPlaybackParameters(new PlaybackParameters(2.5f)); + videoPlayer.setPlaybackSpeed(2.5); + verify(mockExoPlayer).setPlaybackParameters(new PlaybackParameters(2.5f)); - videoPlayer.dispose(); - } + videoPlayer.dispose(); + } - @Test - public void seekAndGetPosition() { - VideoPlayer videoPlayer = createVideoPlayer(); + @Test + public void seekAndGetPosition() { + VideoPlayer videoPlayer = createVideoPlayer(); - videoPlayer.seekTo(10); - verify(mockExoPlayer).seekTo(10); + videoPlayer.seekTo(10); + verify(mockExoPlayer).seekTo(10); - when(mockExoPlayer.getCurrentPosition()).thenReturn(20L); - assertEquals(20L, videoPlayer.getPosition()); - } + when(mockExoPlayer.getCurrentPosition()).thenReturn(20L); + assertEquals(20L, videoPlayer.getPosition()); + } - @Test - public void disposeReleasesTextureAndPlayer() { - VideoPlayer videoPlayer = createVideoPlayer(); - videoPlayer.dispose();; + @Test + public void disposeReleasesTextureAndPlayer() { + VideoPlayer videoPlayer = createVideoPlayer(); + videoPlayer.dispose(); + ; - verify(mockTexture).release(); - verify(mockExoPlayer).release(); - } + verify(mockTexture).release(); + verify(mockExoPlayer).release(); + } } From e0f663cd30579971203f5025aafa7084924f2cc6 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Tue, 25 Jun 2024 08:53:49 -0700 Subject: [PATCH 04/14] Address android_lint. --- .../plugins/videoplayer/LocalVideoAsset.java | 28 +++++ .../plugins/videoplayer/RemoteVideoAsset.java | 97 +++++++++++++++ .../plugins/videoplayer/VideoAsset.java | 117 +----------------- .../plugins/videoplayer/FakeVideoAsset.java | 12 +- .../plugins/videoplayer/VideoAssetTest.java | 22 ++-- .../VideoPlayerEventCallbacksTest.java | 4 + .../plugins/videoplayer/VideoPlayerTest.java | 1 - 7 files changed, 150 insertions(+), 131 deletions(-) create mode 100644 packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java create mode 100644 packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RemoteVideoAsset.java 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 00000000000..3d1b3d850d2 --- /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 00000000000..61f214d3432 --- /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 DynamicAdaptive: + mimeType = MimeTypes.APPLICATION_MPD; + break; + case HttpLive: + 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 index e8431f2ccec..87c7706c09c 100644 --- 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 @@ -1,17 +1,13 @@ +// 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.HashMap; import java.util.Map; @@ -62,19 +58,6 @@ protected VideoAsset(@Nullable String assetUrl) { @NonNull abstract MediaItem getMediaItem(); - /** - * 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 - abstract MediaSource.Factory getMediaSourceFactory( - Context context, DefaultHttpDataSource.Factory initialFactory); - /** * Returns the configured media source factory, if needed for this asset type. * @@ -83,98 +66,6 @@ abstract MediaSource.Factory getMediaSourceFactory( */ abstract MediaSource.Factory getMediaSourceFactory(Context context); - private static final class LocalVideoAsset extends VideoAsset { - private 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); - } - - @Override - MediaSource.Factory getMediaSourceFactory( - Context context, DefaultHttpDataSource.Factory initialFactory) { - return new DefaultMediaSourceFactory(context); - } - } - - private static 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; - - private 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 DynamicAdaptive: - mimeType = MimeTypes.APPLICATION_MPD; - break; - case HttpLive: - 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()); - } - - @Override - MediaSource.Factory getMediaSourceFactory( - Context context, DefaultHttpDataSource.Factory factory) { - String userAgent = DEFAULT_USER_AGENT; - if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) { - userAgent = httpHeaders.get(HEADER_USER_AGENT); - } - unstableUpdateDataSourceFactory(factory, httpHeaders, userAgent); - - DataSource.Factory dataSoruceFactory = new DefaultDataSource.Factory(context, factory); - 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); - } - } - } - /** 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. */ 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 index 7a8f1d58639..16133b5383d 100644 --- 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 @@ -1,3 +1,7 @@ +// 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; @@ -7,8 +11,6 @@ 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; @@ -32,10 +34,4 @@ MediaItem getMediaItem() { MediaSource.Factory getMediaSourceFactory(Context context) { return mediaSourceFactory; } - - @Override - MediaSource.Factory getMediaSourceFactory( - Context context, DefaultHttpDataSource.Factory initialFactory) { - return getMediaSourceFactory(context); - } } 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 index 444f1d3914a..99e9866169c 100644 --- 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 @@ -1,3 +1,7 @@ +// 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; @@ -56,9 +60,9 @@ private static DefaultHttpDataSource.Factory mockHttpFactory() { } @Test - public void remoteVideoByDefaultSetsUserAgentAndCrossProtocolRedirects() throws Exception { - VideoAsset asset = - VideoAsset.fromRemoteUrl( + public void remoteVideoByDefaultSetsUserAgentAndCrossProtocolRedirects() { + RemoteVideoAsset asset = + new RemoteVideoAsset( "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.Unknown, new HashMap<>()); DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); @@ -70,12 +74,12 @@ public void remoteVideoByDefaultSetsUserAgentAndCrossProtocolRedirects() throws } @Test - public void remoteVideoOverridesUserAgentIfProvided() throws Exception { + public void remoteVideoOverridesUserAgentIfProvided() { Map headers = new HashMap<>(); headers.put("User-Agent", "FantasticalVideoBot"); - VideoAsset asset = - VideoAsset.fromRemoteUrl( + RemoteVideoAsset asset = + new RemoteVideoAsset( "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.Unknown, headers); DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); @@ -87,12 +91,12 @@ public void remoteVideoOverridesUserAgentIfProvided() throws Exception { } @Test - public void remoteVideoSetsAdditionalHttpHeadersIfProvided() throws Exception { + public void remoteVideoSetsAdditionalHttpHeadersIfProvided() { Map headers = new HashMap<>(); headers.put("X-Cache-Forever", "true"); - VideoAsset asset = - VideoAsset.fromRemoteUrl( + RemoteVideoAsset asset = + new RemoteVideoAsset( "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.Unknown, headers); DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); 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 index 60b355fa8dd..d955384bc94 100644 --- 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 @@ -1,3 +1,7 @@ +// 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; 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 4a936996a58..a30166c0aa5 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 @@ -173,7 +173,6 @@ public void seekAndGetPosition() { public void disposeReleasesTextureAndPlayer() { VideoPlayer videoPlayer = createVideoPlayer(); videoPlayer.dispose(); - ; verify(mockTexture).release(); verify(mockExoPlayer).release(); From 796fedc1682cd291f53020b2079268893a8871a7 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Tue, 25 Jun 2024 08:59:08 -0700 Subject: [PATCH 05/14] Add back missing gradle dependency thingy. --- packages/video_player/video_player_android/android/build.gradle | 1 + .../test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle index 77f504de422..789d0949572 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/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 index 16133b5383d..1e3b856a648 100644 --- 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 @@ -7,7 +7,6 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.media3.common.MediaItem; -import androidx.media3.datasource.DefaultHttpDataSource; import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.test.utils.FakeMediaSourceFactory; From 0516633d6a4271062540b38c028a423853ad8427 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Tue, 25 Jun 2024 11:38:13 -0700 Subject: [PATCH 06/14] Address feedback. --- .../plugins/videoplayer/RemoteVideoAsset.java | 6 +-- .../plugins/videoplayer/VideoAsset.java | 8 +-- .../videoplayer/VideoPlayerPlugin.java | 8 +-- .../ExoPlayerEventListenerTests.java | 4 ++ .../plugins/videoplayer/VideoAssetTest.java | 53 ++++++++++++++----- 5 files changed, 56 insertions(+), 23 deletions(-) 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 index 61f214d3432..75c3c42d96f 100644 --- 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 @@ -41,13 +41,13 @@ MediaItem getMediaItem() { MediaItem.Builder builder = new MediaItem.Builder().setUri(assetUrl); String mimeType = null; switch (streamingFormat) { - case Smooth: + case SMOOTH: mimeType = MimeTypes.APPLICATION_SS; break; - case DynamicAdaptive: + case DYNAMIC_ADAPTIVE: mimeType = MimeTypes.APPLICATION_MPD; break; - case HttpLive: + case HTTP_LIVE: mimeType = MimeTypes.APPLICATION_M3U8; break; } 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 index 87c7706c09c..2b83437c6fc 100644 --- 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 @@ -69,15 +69,15 @@ protected VideoAsset(@Nullable String assetUrl) { /** 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, + UNKNOWN, /** Smooth Streaming. */ - Smooth, + SMOOTH, /** MPEG-DASH (Dynamic Adaptive over HTTP). */ - DynamicAdaptive, + DYNAMIC_ADAPTIVE, /** HTTP Live Streaming (HLS). */ - HttpLive + HTTP_LIVE } } 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 1fc69eddf6b..0e57068944e 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 @@ -112,18 +112,18 @@ public void initialize() { videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey); } else { Map httpHeaders = arg.getHttpHeaders(); - VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.Unknown; + VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.UNKNOWN; String formatHint = arg.getFormatHint(); if (formatHint != null) { switch (formatHint) { case "ss": - streamingFormat = VideoAsset.StreamingFormat.Smooth; + streamingFormat = VideoAsset.StreamingFormat.SMOOTH; break; case "dash": - streamingFormat = VideoAsset.StreamingFormat.DynamicAdaptive; + streamingFormat = VideoAsset.StreamingFormat.DYNAMIC_ADAPTIVE; break; case "hls": - streamingFormat = VideoAsset.StreamingFormat.HttpLive; + streamingFormat = VideoAsset.StreamingFormat.HTTP_LIVE; break; } } 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 index 6af16024e5f..1d00d31b8ee 100644 --- 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 @@ -1,3 +1,7 @@ +// 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; 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 index 99e9866169c..743bf572aee 100644 --- 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 @@ -17,9 +17,11 @@ 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; @@ -61,12 +63,15 @@ private static DefaultHttpDataSource.Factory mockHttpFactory() { @Test public void remoteVideoByDefaultSetsUserAgentAndCrossProtocolRedirects() { - RemoteVideoAsset asset = - new RemoteVideoAsset( - "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.Unknown, new HashMap<>()); + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, new HashMap<>()); DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); - asset.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + // 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); @@ -78,29 +83,53 @@ public void remoteVideoOverridesUserAgentIfProvided() { Map headers = new HashMap<>(); headers.put("User-Agent", "FantasticalVideoBot"); - RemoteVideoAsset asset = - new RemoteVideoAsset( - "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.Unknown, headers); + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, headers); DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); - asset.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + // 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"); - RemoteVideoAsset asset = - new RemoteVideoAsset( - "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.Unknown, headers); + VideoAsset asset = + VideoAsset.fromRemoteUrl( + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, headers); DefaultHttpDataSource.Factory mockFactory = mockHttpFactory(); - asset.getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory); + + // 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); From 9bd2beb351cd3e7f7b69413acc12919a2ac38ce4 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Tue, 25 Jun 2024 12:49:11 -0700 Subject: [PATCH 07/14] Revert pubspec override. --- .../video_player/video_player_android/example/pubspec.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/video_player/video_player_android/example/pubspec.yaml b/packages/video_player/video_player_android/example/pubspec.yaml index 03d6386bbc4..a6c57dc8d8b 100644 --- a/packages/video_player/video_player_android/example/pubspec.yaml +++ b/packages/video_player/video_player_android/example/pubspec.yaml @@ -31,7 +31,3 @@ flutter: assets: - assets/flutter-mark-square-64.png - assets/Butterfly-209.mp4 - -# FIXME: Remove this override before submitting the example. -dependency_overrides: - win32: 5.5.1 From 3dd75ece57b43e53c159f0e9edc15b74fbfaf79c Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Tue, 25 Jun 2024 14:46:18 -0700 Subject: [PATCH 08/14] Add resume/suspend using the new SurfaceProducer.Callback APIs. --- .../plugins/videoplayer/ExoPlayerState.java | 77 ++++++++++++ .../plugins/videoplayer/VideoPlayer.java | 112 ++++++++++++------ .../videoplayer/VideoPlayerPlugin.java | 6 +- .../plugins/videoplayer/VideoPlayerTest.java | 49 ++++++-- 4 files changed, 192 insertions(+), 52 deletions(-) create mode 100644 packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java new file mode 100644 index 00000000000..67b9f3a92ec --- /dev/null +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java @@ -0,0 +1,77 @@ +// 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 androidx.media3.common.PlaybackParameters; +import androidx.media3.exoplayer.ExoPlayer; + +/** + * Internal state representing an {@link ExoPlayer} instance at a snapshot in time. + * + *

During the Android application lifecycle, the underlying {@link android.view.Surface} being + * rendered to by the player can be destroyed when the application is in the background and memory + * is reclaimed. Upon resume, the player will need to be recreated, but start again at the + * previous point (and settings). + */ +final class ExoPlayerState { + /** + * Saves a representation of the current state of the player at the current point in time. + * + *

The inverse of this operation is {@link #restore(ExoPlayer)}. + * + * @param exoPlayer the active player instance. + * + * @return an opaque object representing the state. + */ + static ExoPlayerState save(ExoPlayer exoPlayer) { + return new ExoPlayerState( + /*position=*/ exoPlayer.getCurrentPosition(), + /*repeatMode=*/ exoPlayer.getRepeatMode(), + /*volume=*/ exoPlayer.getVolume(), + /*playbackParameters=*/ exoPlayer.getPlaybackParameters()); + } + + private ExoPlayerState(long position, int repeatMode, float volume, PlaybackParameters playbackParameters) { + this.position = position; + this.repeatMode = repeatMode; + this.volume = volume; + this.playbackParameters = playbackParameters; + } + + /** + * Previous value of {@link ExoPlayer#getCurrentPosition()}. + */ + private final long position; + + /** + * Previous value of {@link ExoPlayer#getRepeatMode()}. + */ + private final int repeatMode; + + /** + * Previous value of {@link ExoPlayer#getVolume()}. + */ + private final float volume; + + /** + * Previous value of {@link ExoPlayer#getPlaybackParameters()}. + */ + private final PlaybackParameters playbackParameters; + + /** + * Restores the captured state onto the provided player. + * + *

This will typically be done after creating a new player, setting up a media source, and + * listening to events. + * + * @param exoPlayer the new player instance to reflect the state back to. + */ + void restore(ExoPlayer exoPlayer) { + exoPlayer.seekTo(position); + exoPlayer.setRepeatMode(repeatMode); + exoPlayer.setVolume(volume); + exoPlayer.setPlaybackParameters(playbackParameters); + } +} 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 ab20b30f42d..6ed7c325efc 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 @@ -8,70 +8,111 @@ import static androidx.media3.common.Player.REPEAT_MODE_OFF; import android.content.Context; -import android.view.Surface; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.PlaybackParameters; import androidx.media3.exoplayer.ExoPlayer; + import io.flutter.view.TextureRegistry; final class VideoPlayer { - private ExoPlayer exoPlayer; - private Surface surface; - private final TextureRegistry.SurfaceTextureEntry textureEntry; + @NonNull + private final ExoPlayerProvider exoPlayerProvider; + @NonNull + private final MediaItem mediaItem; + @NonNull + private final TextureRegistry.SurfaceProducer surfaceProducer; + @NonNull private final VideoPlayerCallbacks videoPlayerEvents; + @NonNull private final VideoPlayerOptions options; + @NonNull + private ExoPlayer exoPlayer; + @Nullable + private ExoPlayerState savedStateDuring; /** * Creates a video player. * * @param context application context. * @param events event callbacks. - * @param textureEntry texture to render to. + * @param surfaceProducer produces a 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, - VideoAsset asset, - VideoPlayerOptions options) { - ExoPlayer.Builder builder = - new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context)); - return new VideoPlayer(builder, events, textureEntry, asset.getMediaItem(), options); + @NonNull Context context, + @NonNull VideoPlayerCallbacks events, + @NonNull TextureRegistry.SurfaceProducer surfaceProducer, + @NonNull VideoAsset asset, + @NonNull VideoPlayerOptions options) { + return new VideoPlayer(() -> { + ExoPlayer.Builder builder = new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context)); + return builder.build(); + }, events, surfaceProducer, asset.getMediaItem(), options); + } + + /** + * A closure-compatible signature since {@link java.util.function.Supplier} is API level 24. + */ + interface ExoPlayerProvider { + /** + * Returns a new {@link ExoPlayer}. + * + * @return new instance. + */ + ExoPlayer get(); } @VisibleForTesting VideoPlayer( - ExoPlayer.Builder builder, - VideoPlayerCallbacks events, - TextureRegistry.SurfaceTextureEntry textureEntry, - MediaItem mediaItem, - VideoPlayerOptions options) { + @NonNull ExoPlayerProvider exoPlayerProvider, + @NonNull VideoPlayerCallbacks events, + @NonNull TextureRegistry.SurfaceProducer surfaceProducer, + @NonNull MediaItem mediaItem, + @NonNull VideoPlayerOptions options) { + this.exoPlayerProvider = exoPlayerProvider; this.videoPlayerEvents = events; - this.textureEntry = textureEntry; + this.surfaceProducer = surfaceProducer; + this.mediaItem = mediaItem; this.options = options; + this.exoPlayer = createVideoPlayer(); + + surfaceProducer.setCallback(new TextureRegistry.SurfaceProducer.Callback() { + @Override + public void onSurfaceCreated() { + exoPlayer = createVideoPlayer(); + if (savedStateDuring != null) { + savedStateDuring.restore(exoPlayer); + savedStateDuring = null; + } + } + + @Override + public void onSurfaceDestroyed() { + exoPlayer.stop(); + savedStateDuring = ExoPlayerState.save(exoPlayer); + exoPlayer.release(); + } + }); + } - ExoPlayer exoPlayer = builder.build(); + private ExoPlayer createVideoPlayer() { + ExoPlayer exoPlayer = exoPlayerProvider.get(); exoPlayer.setMediaItem(mediaItem); exoPlayer.prepare(); - setUpVideoPlayer(exoPlayer); - } - - private void setUpVideoPlayer(ExoPlayer exoPlayer) { - this.exoPlayer = exoPlayer; - - surface = new Surface(textureEntry.surfaceTexture()); - exoPlayer.setVideoSurface(surface); - setAudioAttributes(exoPlayer, options.mixWithOthers); + exoPlayer.setVideoSurface(surfaceProducer.getSurface()); exoPlayer.addListener(new ExoPlayerEventListener(exoPlayer, videoPlayerEvents)); + setAudioAttributes(exoPlayer, options.mixWithOthers); + + return exoPlayer; } void sendBufferingUpdate() { @@ -85,11 +126,11 @@ private static void setAudioAttributes(ExoPlayer exoPlayer, boolean isMixMode) { } void play() { - exoPlayer.setPlayWhenReady(true); + exoPlayer.play(); } void pause() { - exoPlayer.setPlayWhenReady(false); + exoPlayer.pause(); } void setLooping(boolean value) { @@ -118,12 +159,7 @@ long getPosition() { } void dispose() { - textureEntry.release(); - if (surface != null) { - surface.release(); - } - if (exoPlayer != null) { - exoPlayer.release(); - } + surfaceProducer.release(); + exoPlayer.release(); } } 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 0e57068944e..90359471aff 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.Map; import javax.net.ssl.HttpsURLConnection; /** Android platform implementation of the VideoPlayerPlugin. */ @@ -94,8 +93,8 @@ public void initialize() { } public @NonNull TextureMessage create(@NonNull CreateMessage arg) { - TextureRegistry.SurfaceTextureEntry handle = - flutterState.textureRegistry.createSurfaceTexture(); + TextureRegistry.SurfaceProducer handle = + flutterState.textureRegistry.createSurfaceProducer(); EventChannel eventChannel = new EventChannel( flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + handle.id()); @@ -111,7 +110,6 @@ public void initialize() { } videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey); } else { - Map httpHeaders = arg.getHttpHeaders(); VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.UNKNOWN; String formatHint = arg.getFormatHint(); if (formatHint != null) { 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 a30166c0aa5..6adb4097c2f 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 @@ -8,7 +8,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import android.graphics.SurfaceTexture; +import android.view.Surface; + import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; import androidx.media3.common.PlaybackParameters; @@ -44,18 +45,17 @@ public final class VideoPlayerTest { private FakeVideoAsset fakeVideoAsset; @Mock private VideoPlayerCallbacks mockEvents; - @Mock private TextureRegistry.SurfaceTextureEntry mockTexture; - @Mock private ExoPlayer.Builder mockBuilder; + @Mock private TextureRegistry.SurfaceProducer mockProducer; @Mock private ExoPlayer mockExoPlayer; @Captor private ArgumentCaptor attributesCaptor; + @Captor private ArgumentCaptor callbackCaptor; @Rule public MockitoRule initRule = MockitoJUnit.rule(); @Before public void setUp() { fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL); - when(mockBuilder.build()).thenReturn(mockExoPlayer); - when(mockTexture.surfaceTexture()).thenReturn(mock(SurfaceTexture.class)); + when(mockProducer.getSurface()).thenReturn(mock(Surface.class)); } private VideoPlayer createVideoPlayer() { @@ -64,7 +64,7 @@ private VideoPlayer createVideoPlayer() { private VideoPlayer createVideoPlayer(VideoPlayerOptions options) { return new VideoPlayer( - mockBuilder, mockEvents, mockTexture, fakeVideoAsset.getMediaItem(), options); + () -> mockExoPlayer, mockEvents, mockProducer, fakeVideoAsset.getMediaItem(), options); } @Test @@ -73,7 +73,7 @@ public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() { verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem()); verify(mockExoPlayer).prepare(); - verify(mockTexture).surfaceTexture(); + verify(mockProducer).getSurface(); verify(mockExoPlayer).setVideoSurface(any()); verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true)); @@ -100,10 +100,10 @@ public void playsAndPausesProvidedMedia() { VideoPlayer videoPlayer = createVideoPlayer(); videoPlayer.play(); - verify(mockExoPlayer).setPlayWhenReady(true); + verify(mockExoPlayer).play(); videoPlayer.pause(); - verify(mockExoPlayer).setPlayWhenReady(false); + verify(mockExoPlayer).pause(); videoPlayer.dispose(); } @@ -169,12 +169,41 @@ public void seekAndGetPosition() { assertEquals(20L, videoPlayer.getPosition()); } + @Test + public void onSurfaceProducerDestroyedAndRecreatedReleasesAndThenRecreatesAndResumesPlayer() { + VideoPlayer videoPlayer = createVideoPlayer(); + + verify(mockProducer).setCallback(callbackCaptor.capture()); + verify(mockExoPlayer, never()).release(); + + when(mockExoPlayer.getCurrentPosition()).thenReturn(10L); + when(mockExoPlayer.getRepeatMode()).thenReturn(Player.REPEAT_MODE_ALL); + when(mockExoPlayer.getVolume()).thenReturn(0.5f); + when(mockExoPlayer.getPlaybackParameters()).thenReturn(new PlaybackParameters(2.5f)); + + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue(); + producerLifecycle.onSurfaceDestroyed(); + + verify(mockExoPlayer).release(); + + // Create a new mock exo player so that we get a new instance. + mockExoPlayer = mock(ExoPlayer.class); + producerLifecycle.onSurfaceCreated(); + + verify(mockExoPlayer).seekTo(10L); + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_ALL); + verify(mockExoPlayer).setVolume(0.5f); + verify(mockExoPlayer).setPlaybackParameters(new PlaybackParameters(2.5f)); + + videoPlayer.dispose(); + } + @Test public void disposeReleasesTextureAndPlayer() { VideoPlayer videoPlayer = createVideoPlayer(); videoPlayer.dispose(); - verify(mockTexture).release(); + verify(mockProducer).release(); verify(mockExoPlayer).release(); } } From a1dfdcfac0b2a25bec5b7dfee1bb230dabf0e0cf Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Tue, 25 Jun 2024 15:02:14 -0700 Subject: [PATCH 09/14] Fmt. --- .../plugins/videoplayer/ExoPlayerState.java | 96 +++++++++---------- .../plugins/videoplayer/VideoPlayer.java | 76 +++++++-------- .../videoplayer/VideoPlayerPlugin.java | 3 +- .../plugins/videoplayer/VideoPlayerTest.java | 3 +- 4 files changed, 83 insertions(+), 95 deletions(-) diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java index 67b9f3a92ec..cd55b54c124 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java @@ -16,62 +16,54 @@ * previous point (and settings). */ final class ExoPlayerState { - /** - * Saves a representation of the current state of the player at the current point in time. - * - *

The inverse of this operation is {@link #restore(ExoPlayer)}. - * - * @param exoPlayer the active player instance. - * - * @return an opaque object representing the state. - */ - static ExoPlayerState save(ExoPlayer exoPlayer) { - return new ExoPlayerState( - /*position=*/ exoPlayer.getCurrentPosition(), - /*repeatMode=*/ exoPlayer.getRepeatMode(), - /*volume=*/ exoPlayer.getVolume(), - /*playbackParameters=*/ exoPlayer.getPlaybackParameters()); - } + /** + * Saves a representation of the current state of the player at the current point in time. + * + *

The inverse of this operation is {@link #restore(ExoPlayer)}. + * + * @param exoPlayer the active player instance. + * @return an opaque object representing the state. + */ + static ExoPlayerState save(ExoPlayer exoPlayer) { + return new ExoPlayerState( + /*position=*/ exoPlayer.getCurrentPosition(), + /*repeatMode=*/ exoPlayer.getRepeatMode(), + /*volume=*/ exoPlayer.getVolume(), + /*playbackParameters=*/ exoPlayer.getPlaybackParameters()); + } - private ExoPlayerState(long position, int repeatMode, float volume, PlaybackParameters playbackParameters) { - this.position = position; - this.repeatMode = repeatMode; - this.volume = volume; - this.playbackParameters = playbackParameters; - } + private ExoPlayerState( + long position, int repeatMode, float volume, PlaybackParameters playbackParameters) { + this.position = position; + this.repeatMode = repeatMode; + this.volume = volume; + this.playbackParameters = playbackParameters; + } - /** - * Previous value of {@link ExoPlayer#getCurrentPosition()}. - */ - private final long position; + /** Previous value of {@link ExoPlayer#getCurrentPosition()}. */ + private final long position; - /** - * Previous value of {@link ExoPlayer#getRepeatMode()}. - */ - private final int repeatMode; + /** Previous value of {@link ExoPlayer#getRepeatMode()}. */ + private final int repeatMode; - /** - * Previous value of {@link ExoPlayer#getVolume()}. - */ - private final float volume; + /** Previous value of {@link ExoPlayer#getVolume()}. */ + private final float volume; - /** - * Previous value of {@link ExoPlayer#getPlaybackParameters()}. - */ - private final PlaybackParameters playbackParameters; + /** Previous value of {@link ExoPlayer#getPlaybackParameters()}. */ + private final PlaybackParameters playbackParameters; - /** - * Restores the captured state onto the provided player. - * - *

This will typically be done after creating a new player, setting up a media source, and - * listening to events. - * - * @param exoPlayer the new player instance to reflect the state back to. - */ - void restore(ExoPlayer exoPlayer) { - exoPlayer.seekTo(position); - exoPlayer.setRepeatMode(repeatMode); - exoPlayer.setVolume(volume); - exoPlayer.setPlaybackParameters(playbackParameters); - } + /** + * Restores the captured state onto the provided player. + * + *

This will typically be done after creating a new player, setting up a media source, and + * listening to events. + * + * @param exoPlayer the new player instance to reflect the state back to. + */ + void restore(ExoPlayer exoPlayer) { + exoPlayer.seekTo(position); + exoPlayer.setRepeatMode(repeatMode); + exoPlayer.setVolume(volume); + exoPlayer.setPlaybackParameters(playbackParameters); + } } 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 6ed7c325efc..2001b3fcd24 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 @@ -16,24 +16,16 @@ import androidx.media3.common.MediaItem; import androidx.media3.common.PlaybackParameters; import androidx.media3.exoplayer.ExoPlayer; - import io.flutter.view.TextureRegistry; final class VideoPlayer { - @NonNull - private final ExoPlayerProvider exoPlayerProvider; - @NonNull - private final MediaItem mediaItem; - @NonNull - private final TextureRegistry.SurfaceProducer surfaceProducer; - @NonNull - private final VideoPlayerCallbacks videoPlayerEvents; - @NonNull - private final VideoPlayerOptions options; - @NonNull - private ExoPlayer exoPlayer; - @Nullable - private ExoPlayerState savedStateDuring; + @NonNull private final ExoPlayerProvider exoPlayerProvider; + @NonNull private final MediaItem mediaItem; + @NonNull private final TextureRegistry.SurfaceProducer surfaceProducer; + @NonNull private final VideoPlayerCallbacks videoPlayerEvents; + @NonNull private final VideoPlayerOptions options; + @NonNull private ExoPlayer exoPlayer; + @Nullable private ExoPlayerState savedStateDuring; /** * Creates a video player. @@ -52,15 +44,20 @@ static VideoPlayer create( @NonNull TextureRegistry.SurfaceProducer surfaceProducer, @NonNull VideoAsset asset, @NonNull VideoPlayerOptions options) { - return new VideoPlayer(() -> { - ExoPlayer.Builder builder = new ExoPlayer.Builder(context).setMediaSourceFactory(asset.getMediaSourceFactory(context)); - return builder.build(); - }, events, surfaceProducer, asset.getMediaItem(), options); + return new VideoPlayer( + () -> { + ExoPlayer.Builder builder = + new ExoPlayer.Builder(context) + .setMediaSourceFactory(asset.getMediaSourceFactory(context)); + return builder.build(); + }, + events, + surfaceProducer, + asset.getMediaItem(), + options); } - /** - * A closure-compatible signature since {@link java.util.function.Supplier} is API level 24. - */ + /** A closure-compatible signature since {@link java.util.function.Supplier} is API level 24. */ interface ExoPlayerProvider { /** * Returns a new {@link ExoPlayer}. @@ -84,23 +81,24 @@ interface ExoPlayerProvider { this.options = options; this.exoPlayer = createVideoPlayer(); - surfaceProducer.setCallback(new TextureRegistry.SurfaceProducer.Callback() { - @Override - public void onSurfaceCreated() { - exoPlayer = createVideoPlayer(); - if (savedStateDuring != null) { - savedStateDuring.restore(exoPlayer); - savedStateDuring = null; - } - } - - @Override - public void onSurfaceDestroyed() { - exoPlayer.stop(); - savedStateDuring = ExoPlayerState.save(exoPlayer); - exoPlayer.release(); - } - }); + surfaceProducer.setCallback( + new TextureRegistry.SurfaceProducer.Callback() { + @Override + public void onSurfaceCreated() { + exoPlayer = createVideoPlayer(); + if (savedStateDuring != null) { + savedStateDuring.restore(exoPlayer); + savedStateDuring = null; + } + } + + @Override + public void onSurfaceDestroyed() { + exoPlayer.stop(); + savedStateDuring = ExoPlayerState.save(exoPlayer); + exoPlayer.release(); + } + }); } private ExoPlayer createVideoPlayer() { 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 90359471aff..9152cd89f2c 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 @@ -93,8 +93,7 @@ public void initialize() { } public @NonNull TextureMessage create(@NonNull CreateMessage arg) { - TextureRegistry.SurfaceProducer handle = - flutterState.textureRegistry.createSurfaceProducer(); + TextureRegistry.SurfaceProducer handle = flutterState.textureRegistry.createSurfaceProducer(); EventChannel eventChannel = new EventChannel( flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + handle.id()); 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 6adb4097c2f..1b7ff407119 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 @@ -9,7 +9,6 @@ import static org.mockito.Mockito.*; import android.view.Surface; - import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; import androidx.media3.common.PlaybackParameters; @@ -64,7 +63,7 @@ private VideoPlayer createVideoPlayer() { private VideoPlayer createVideoPlayer(VideoPlayerOptions options) { return new VideoPlayer( - () -> mockExoPlayer, mockEvents, mockProducer, fakeVideoAsset.getMediaItem(), options); + () -> mockExoPlayer, mockEvents, mockProducer, fakeVideoAsset.getMediaItem(), options); } @Test From 3d7bc67c6ed2a7f7e52848364f5a4d689cea45ac Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Thu, 22 Aug 2024 11:47:58 -0700 Subject: [PATCH 10/14] Delint and stuff. --- .../video_player_android/CHANGELOG.md | 118 +++++++++--------- .../plugins/videoplayer/VideoPlayer.java | 7 +- .../video_player_android/pubspec.yaml | 2 +- 3 files changed, 68 insertions(+), 59 deletions(-) diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md index e6815fce6ef..78c52d7233d 100644 --- a/packages/video_player/video_player_android/CHANGELOG.md +++ b/packages/video_player/video_player_android/CHANGELOG.md @@ -1,172 +1,176 @@ +## 2.7.0 + +- Re-adds [support for Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). + ## 2.6.0 -* Adds RTSP support. +- Adds RTSP support. ## 2.5.4 -* Updates Media3-ExoPlayer to 1.4.0. +- Updates Media3-ExoPlayer to 1.4.0. ## 2.5.3 -* Updates lint checks to ignore NewerVersionAvailable. +- Updates lint checks to ignore NewerVersionAvailable. ## 2.5.2 -* Updates Android Gradle plugin to 8.5.0. +- Updates Android Gradle plugin to 8.5.0. ## 2.5.1 -* Removes additional references to the v1 Android embedding. +- Removes additional references to the v1 Android embedding. ## 2.5.0 -* Migrates ExoPlayer to Media3-ExoPlayer 1.3.1. +- Migrates ExoPlayer to Media3-ExoPlayer 1.3.1. ## 2.4.17 -* Revert Impeller support. +- Revert Impeller support. ## 2.4.16 -* [Supports Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). +- [Supports Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). ## 2.4.15 -* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. -* Removes support for apps using the v1 Android embedding. +- Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +- Removes support for apps using the v1 Android embedding. ## 2.4.14 -* Calls `onDestroy` instead of `initialize` in onDetachedFromEngine. +- Calls `onDestroy` instead of `initialize` in onDetachedFromEngine. ## 2.4.13 -* Updates minSdkVersion to 19. -* Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. +- Updates minSdkVersion to 19. +- Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. ## 2.4.12 -* Updates compileSdk version to 34. -* Adds error handling for `BehindLiveWindowException`, which may occur upon live-video playback failure. +- Updates compileSdk version to 34. +- Adds error handling for `BehindLiveWindowException`, which may occur upon live-video playback failure. ## 2.4.11 -* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. -* Fixes new lint warnings. +- Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. +- Fixes new lint warnings. ## 2.4.10 -* Adds pub topics to package metadata. -* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. +- Adds pub topics to package metadata. +- Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. ## 2.4.9 -* Bumps ExoPlayer version to 2.18.7. +- Bumps ExoPlayer version to 2.18.7. ## 2.4.8 -* Bumps ExoPlayer version to 2.18.6. +- Bumps ExoPlayer version to 2.18.6. ## 2.4.7 -* Fixes Java warnings. +- Fixes Java warnings. ## 2.4.6 -* Fixes compatibility with AGP versions older than 4.2. +- Fixes compatibility with AGP versions older than 4.2. ## 2.4.5 -* Adds a namespace for compatibility with AGP 8.0. +- Adds a namespace for compatibility with AGP 8.0. ## 2.4.4 -* Synchronizes `VideoPlayerValue.isPlaying` with `ExoPlayer`. -* Updates minimum Flutter version to 3.3. +- Synchronizes `VideoPlayerValue.isPlaying` with `ExoPlayer`. +- Updates minimum Flutter version to 3.3. ## 2.4.3 -* Bumps ExoPlayer version to 2.18.5. +- Bumps ExoPlayer version to 2.18.5. ## 2.4.2 -* Bumps ExoPlayer version to 2.18.4. +- Bumps ExoPlayer version to 2.18.4. ## 2.4.1 -* Changes the severity of `javac` warnings so that they are treated as errors and fixes the violations. +- Changes the severity of `javac` warnings so that they are treated as errors and fixes the violations. ## 2.4.0 -* Allows setting the ExoPlayer user agent by passing a User-Agent HTTP header. +- Allows setting the ExoPlayer user agent by passing a User-Agent HTTP header. ## 2.3.12 -* Clarifies explanation of endorsement in README. -* Aligns Dart and Flutter SDK constraints. -* Updates compileSdkVersion to 33. +- Clarifies explanation of endorsement in README. +- Aligns Dart and Flutter SDK constraints. +- Updates compileSdkVersion to 33. ## 2.3.11 -* Updates links for the merge of flutter/plugins into flutter/packages. -* Updates minimum Flutter version to 3.0. +- Updates links for the merge of flutter/plugins into flutter/packages. +- Updates minimum Flutter version to 3.0. ## 2.3.10 -* Adds compatibilty with version 6.0 of the platform interface. -* Fixes file URI construction in example. -* Updates code for new analysis options. -* Updates code for `no_leading_underscores_for_local_identifiers` lint. -* Updates minimum Flutter version to 2.10. -* Fixes violations of new analysis option use_named_constants. -* Removes an unnecessary override in example code. +- Adds compatibilty with version 6.0 of the platform interface. +- Fixes file URI construction in example. +- Updates code for new analysis options. +- Updates code for `no_leading_underscores_for_local_identifiers` lint. +- Updates minimum Flutter version to 2.10. +- Fixes violations of new analysis option use_named_constants. +- Removes an unnecessary override in example code. ## 2.3.9 -* Updates ExoPlayer to 2.18.1. -* Fixes avoid_redundant_argument_values lint warnings and minor typos. +- Updates ExoPlayer to 2.18.1. +- Fixes avoid_redundant_argument_values lint warnings and minor typos. ## 2.3.8 -* Updates ExoPlayer to 2.18.0. +- Updates ExoPlayer to 2.18.0. ## 2.3.7 -* Bumps gradle version to 7.2.1. -* Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). +- Bumps gradle version to 7.2.1. +- Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). ## 2.3.6 -* Updates references to the obsolete master branch. +- Updates references to the obsolete master branch. ## 2.3.5 -* Sets rotationCorrection for videos recorded in landscapeRight (https://github.com/flutter/flutter/issues/60327). +- Sets rotationCorrection for videos recorded in landscapeRight (https://github.com/flutter/flutter/issues/60327). ## 2.3.4 -* Updates ExoPlayer to 2.17.1. +- Updates ExoPlayer to 2.17.1. ## 2.3.3 -* Removes unnecessary imports. -* Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors +- Removes unnecessary imports. +- Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors lint warnings. ## 2.3.2 -* Updates ExoPlayer to 2.17.0. +- Updates ExoPlayer to 2.17.0. ## 2.3.1 -* Renames internal method channels to avoid potential confusion with the +- Renames internal method channels to avoid potential confusion with the default implementation's method channel. -* Updates Pigeon to 2.0.1. +- Updates Pigeon to 2.0.1. ## 2.3.0 -* Updates Pigeon to ^1.0.16. +- Updates Pigeon to ^1.0.16. ## 2.2.17 -* Splits from `video_player` as a federated implementation. +- Splits from `video_player` as a federated implementation. 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 2001b3fcd24..3e083359b53 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 @@ -85,7 +85,7 @@ interface ExoPlayerProvider { new TextureRegistry.SurfaceProducer.Callback() { @Override public void onSurfaceCreated() { - exoPlayer = createVideoPlayer(); + setExoPlayer(createVideoPlayer()); if (savedStateDuring != null) { savedStateDuring.restore(exoPlayer); savedStateDuring = null; @@ -101,6 +101,11 @@ public void onSurfaceDestroyed() { }); } + // Used to avoid synthetic accessor. + private void setExoPlayer(@NonNull ExoPlayer exoPlayer) { + this.exoPlayer = exoPlayer; + } + private ExoPlayer createVideoPlayer() { ExoPlayer exoPlayer = exoPlayerProvider.get(); exoPlayer.setMediaItem(mediaItem); diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml index ed629fa91f8..25094c9186b 100644 --- a/packages/video_player/video_player_android/pubspec.yaml +++ b/packages/video_player/video_player_android/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_android description: Android implementation of the video_player plugin. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.6.0 +version: 2.7.0 environment: sdk: ^3.4.0 From 064d40f13c20da32a4af659daf744f0d85abadc6 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Thu, 22 Aug 2024 14:57:30 -0700 Subject: [PATCH 11/14] Delint. --- .../plugins/videoplayer/VideoPlayer.java | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) 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 3e083359b53..95eaae01d33 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 @@ -85,27 +85,22 @@ interface ExoPlayerProvider { new TextureRegistry.SurfaceProducer.Callback() { @Override public void onSurfaceCreated() { - setExoPlayer(createVideoPlayer()); - if (savedStateDuring != null) { - savedStateDuring.restore(exoPlayer); - savedStateDuring = null; + VideoPlayer.this.exoPlayer = VideoPlayer.this.createVideoPlayer(); + if (VideoPlayer.this.savedStateDuring != null) { + VideoPlayer.this.savedStateDuring.restore(VideoPlayer.this.exoPlayer); + VideoPlayer.this.savedStateDuring = null; } } @Override public void onSurfaceDestroyed() { - exoPlayer.stop(); - savedStateDuring = ExoPlayerState.save(exoPlayer); - exoPlayer.release(); + VideoPlayer.this.exoPlayer.stop(); + VideoPlayer.this.savedStateDuring = ExoPlayerState.save(VideoPlayer.this.exoPlayer); + VideoPlayer.this.exoPlayer.release(); } }); } - // Used to avoid synthetic accessor. - private void setExoPlayer(@NonNull ExoPlayer exoPlayer) { - this.exoPlayer = exoPlayer; - } - private ExoPlayer createVideoPlayer() { ExoPlayer exoPlayer = exoPlayerProvider.get(); exoPlayer.setMediaItem(mediaItem); From b07e1e2b341d5bcedca440694fe90c8b1e6faa7a Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Fri, 23 Aug 2024 11:38:19 -0700 Subject: [PATCH 12/14] Try another approach. --- .../plugins/videoplayer/VideoPlayer.java | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) 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 95eaae01d33..6fcfbe9f37b 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,6 +10,7 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; @@ -18,7 +19,7 @@ import androidx.media3.exoplayer.ExoPlayer; import io.flutter.view.TextureRegistry; -final class VideoPlayer { +final class VideoPlayer implements TextureRegistry.SurfaceProducer.Callback { @NonNull private final ExoPlayerProvider exoPlayerProvider; @NonNull private final MediaItem mediaItem; @NonNull private final TextureRegistry.SurfaceProducer surfaceProducer; @@ -80,25 +81,23 @@ interface ExoPlayerProvider { this.mediaItem = mediaItem; this.options = options; this.exoPlayer = createVideoPlayer(); + surfaceProducer.setCallback(this); + } + + @RestrictTo(RestrictTo.Scope.LIBRARY) + public void onSurfaceCreated() { + exoPlayer = createVideoPlayer(); + if (savedStateDuring != null) { + savedStateDuring.restore(exoPlayer); + savedStateDuring = null; + } + } - surfaceProducer.setCallback( - new TextureRegistry.SurfaceProducer.Callback() { - @Override - public void onSurfaceCreated() { - VideoPlayer.this.exoPlayer = VideoPlayer.this.createVideoPlayer(); - if (VideoPlayer.this.savedStateDuring != null) { - VideoPlayer.this.savedStateDuring.restore(VideoPlayer.this.exoPlayer); - VideoPlayer.this.savedStateDuring = null; - } - } - - @Override - public void onSurfaceDestroyed() { - VideoPlayer.this.exoPlayer.stop(); - VideoPlayer.this.savedStateDuring = ExoPlayerState.save(VideoPlayer.this.exoPlayer); - VideoPlayer.this.exoPlayer.release(); - } - }); + @RestrictTo(RestrictTo.Scope.LIBRARY) + public void onSurfaceDestroyed () { + exoPlayer.stop(); + savedStateDuring = ExoPlayerState.save(exoPlayer); + exoPlayer.release(); } private ExoPlayer createVideoPlayer() { From cd573f1fcbb1faac542f6b09525686a2d84fe7a6 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Fri, 23 Aug 2024 11:48:03 -0700 Subject: [PATCH 13/14] ++ --- .../main/java/io/flutter/plugins/videoplayer/VideoPlayer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6fcfbe9f37b..8b12c8a2a0b 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 @@ -94,7 +94,7 @@ public void onSurfaceCreated() { } @RestrictTo(RestrictTo.Scope.LIBRARY) - public void onSurfaceDestroyed () { + public void onSurfaceDestroyed() { exoPlayer.stop(); savedStateDuring = ExoPlayerState.save(exoPlayer); exoPlayer.release(); From f457a3a3db45e735d608b6638369f27c8a19b11a Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Fri, 23 Aug 2024 12:03:37 -0700 Subject: [PATCH 14/14] ++ --- .../video_player_android/CHANGELOG.md | 116 +++++++++--------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md index 78c52d7233d..721c08cdbf5 100644 --- a/packages/video_player/video_player_android/CHANGELOG.md +++ b/packages/video_player/video_player_android/CHANGELOG.md @@ -1,176 +1,176 @@ ## 2.7.0 -- Re-adds [support for Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). +* Re-adds [support for Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). ## 2.6.0 -- Adds RTSP support. +* Adds RTSP support. ## 2.5.4 -- Updates Media3-ExoPlayer to 1.4.0. +* Updates Media3-ExoPlayer to 1.4.0. ## 2.5.3 -- Updates lint checks to ignore NewerVersionAvailable. +* Updates lint checks to ignore NewerVersionAvailable. ## 2.5.2 -- Updates Android Gradle plugin to 8.5.0. +* Updates Android Gradle plugin to 8.5.0. ## 2.5.1 -- Removes additional references to the v1 Android embedding. +* Removes additional references to the v1 Android embedding. ## 2.5.0 -- Migrates ExoPlayer to Media3-ExoPlayer 1.3.1. +* Migrates ExoPlayer to Media3-ExoPlayer 1.3.1. ## 2.4.17 -- Revert Impeller support. +* Revert Impeller support. ## 2.4.16 -- [Supports Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). +* [Supports Impeller](https://docs.flutter.dev/release/breaking-changes/android-surface-plugins). ## 2.4.15 -- Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. -- Removes support for apps using the v1 Android embedding. +* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Removes support for apps using the v1 Android embedding. ## 2.4.14 -- Calls `onDestroy` instead of `initialize` in onDetachedFromEngine. +* Calls `onDestroy` instead of `initialize` in onDetachedFromEngine. ## 2.4.13 -- Updates minSdkVersion to 19. -- Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. +* Updates minSdkVersion to 19. +* Updates minimum supported SDK version to Flutter 3.16/Dart 3.2. ## 2.4.12 -- Updates compileSdk version to 34. -- Adds error handling for `BehindLiveWindowException`, which may occur upon live-video playback failure. +* Updates compileSdk version to 34. +* Adds error handling for `BehindLiveWindowException`, which may occur upon live-video playback failure. ## 2.4.11 -- Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. -- Fixes new lint warnings. +* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. +* Fixes new lint warnings. ## 2.4.10 -- Adds pub topics to package metadata. -- Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. +* Adds pub topics to package metadata. +* Updates minimum supported SDK version to Flutter 3.7/Dart 2.19. ## 2.4.9 -- Bumps ExoPlayer version to 2.18.7. +* Bumps ExoPlayer version to 2.18.7. ## 2.4.8 -- Bumps ExoPlayer version to 2.18.6. +* Bumps ExoPlayer version to 2.18.6. ## 2.4.7 -- Fixes Java warnings. +* Fixes Java warnings. ## 2.4.6 -- Fixes compatibility with AGP versions older than 4.2. +* Fixes compatibility with AGP versions older than 4.2. ## 2.4.5 -- Adds a namespace for compatibility with AGP 8.0. +* Adds a namespace for compatibility with AGP 8.0. ## 2.4.4 -- Synchronizes `VideoPlayerValue.isPlaying` with `ExoPlayer`. -- Updates minimum Flutter version to 3.3. +* Synchronizes `VideoPlayerValue.isPlaying` with `ExoPlayer`. +* Updates minimum Flutter version to 3.3. ## 2.4.3 -- Bumps ExoPlayer version to 2.18.5. +* Bumps ExoPlayer version to 2.18.5. ## 2.4.2 -- Bumps ExoPlayer version to 2.18.4. +* Bumps ExoPlayer version to 2.18.4. ## 2.4.1 -- Changes the severity of `javac` warnings so that they are treated as errors and fixes the violations. +* Changes the severity of `javac` warnings so that they are treated as errors and fixes the violations. ## 2.4.0 -- Allows setting the ExoPlayer user agent by passing a User-Agent HTTP header. +* Allows setting the ExoPlayer user agent by passing a User-Agent HTTP header. ## 2.3.12 -- Clarifies explanation of endorsement in README. -- Aligns Dart and Flutter SDK constraints. -- Updates compileSdkVersion to 33. +* Clarifies explanation of endorsement in README. +* Aligns Dart and Flutter SDK constraints. +* Updates compileSdkVersion to 33. ## 2.3.11 -- Updates links for the merge of flutter/plugins into flutter/packages. -- Updates minimum Flutter version to 3.0. +* Updates links for the merge of flutter/plugins into flutter/packages. +* Updates minimum Flutter version to 3.0. ## 2.3.10 -- Adds compatibilty with version 6.0 of the platform interface. -- Fixes file URI construction in example. -- Updates code for new analysis options. -- Updates code for `no_leading_underscores_for_local_identifiers` lint. -- Updates minimum Flutter version to 2.10. -- Fixes violations of new analysis option use_named_constants. -- Removes an unnecessary override in example code. +* Adds compatibilty with version 6.0 of the platform interface. +* Fixes file URI construction in example. +* Updates code for new analysis options. +* Updates code for `no_leading_underscores_for_local_identifiers` lint. +* Updates minimum Flutter version to 2.10. +* Fixes violations of new analysis option use_named_constants. +* Removes an unnecessary override in example code. ## 2.3.9 -- Updates ExoPlayer to 2.18.1. -- Fixes avoid_redundant_argument_values lint warnings and minor typos. +* Updates ExoPlayer to 2.18.1. +* Fixes avoid_redundant_argument_values lint warnings and minor typos. ## 2.3.8 -- Updates ExoPlayer to 2.18.0. +* Updates ExoPlayer to 2.18.0. ## 2.3.7 -- Bumps gradle version to 7.2.1. -- Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). +* Bumps gradle version to 7.2.1. +* Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). ## 2.3.6 -- Updates references to the obsolete master branch. +* Updates references to the obsolete master branch. ## 2.3.5 -- Sets rotationCorrection for videos recorded in landscapeRight (https://github.com/flutter/flutter/issues/60327). +* Sets rotationCorrection for videos recorded in landscapeRight (https://github.com/flutter/flutter/issues/60327). ## 2.3.4 -- Updates ExoPlayer to 2.17.1. +* Updates ExoPlayer to 2.17.1. ## 2.3.3 -- Removes unnecessary imports. -- Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors +* Removes unnecessary imports. +* Fixes library_private_types_in_public_api, sort_child_properties_last and use_key_in_widget_constructors lint warnings. ## 2.3.2 -- Updates ExoPlayer to 2.17.0. +* Updates ExoPlayer to 2.17.0. ## 2.3.1 -- Renames internal method channels to avoid potential confusion with the +* Renames internal method channels to avoid potential confusion with the default implementation's method channel. -- Updates Pigeon to 2.0.1. +* Updates Pigeon to 2.0.1. ## 2.3.0 -- Updates Pigeon to ^1.0.16. +* Updates Pigeon to ^1.0.16. ## 2.2.17 -- Splits from `video_player` as a federated implementation. +* Splits from `video_player` as a federated implementation.