diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java new file mode 100644 index 000000000000..0940cc8b3227 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java @@ -0,0 +1,101 @@ +// 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.annotation.NonNull; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.common.VideoSize; +import androidx.media3.exoplayer.ExoPlayer; + +final class ExoPlayerEventListener implements Player.Listener { + private final ExoPlayer exoPlayer; + private final VideoPlayerCallbacks events; + private boolean isBuffering = false; + private boolean isInitialized = false; + + ExoPlayerEventListener(ExoPlayer exoPlayer, VideoPlayerCallbacks events) { + this.exoPlayer = exoPlayer; + this.events = events; + } + + private void setBuffering(boolean buffering) { + if (isBuffering == buffering) { + return; + } + isBuffering = buffering; + if (buffering) { + events.onBufferingStart(); + } else { + events.onBufferingEnd(); + } + } + + @SuppressWarnings("SuspiciousNameCombination") + private void sendInitialized() { + if (isInitialized) { + return; + } + isInitialized = true; + VideoSize videoSize = exoPlayer.getVideoSize(); + int rotationCorrection = 0; + int width = videoSize.width; + int height = videoSize.height; + if (width != 0 && height != 0) { + int rotationDegrees = videoSize.unappliedRotationDegrees; + // Switch the width/height if video was taken in portrait mode + if (rotationDegrees == 90 || rotationDegrees == 270) { + width = videoSize.height; + height = videoSize.width; + } + // Rotating the video with ExoPlayer does not seem to be possible with a Surface, + // so inform the Flutter code that the widget needs to be rotated to prevent + // upside-down playback for videos with rotationDegrees of 180 (other orientations work + // correctly without correction). + if (rotationDegrees == 180) { + rotationCorrection = rotationDegrees; + } + } + events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection); + } + + @Override + public void onPlaybackStateChanged(final int playbackState) { + switch (playbackState) { + case Player.STATE_BUFFERING: + setBuffering(true); + events.onBufferingUpdate(exoPlayer.getBufferedPosition()); + break; + case Player.STATE_READY: + sendInitialized(); + break; + case Player.STATE_ENDED: + events.onCompleted(); + break; + case Player.STATE_IDLE: + break; + } + if (playbackState != Player.STATE_BUFFERING) { + setBuffering(false); + } + } + + @Override + public void onPlayerError(@NonNull final PlaybackException error) { + setBuffering(false); + if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { + // See https://exoplayer.dev/live-streaming.html#behindlivewindowexception-and-error_code_behind_live_window + exoPlayer.seekToDefaultPosition(); + exoPlayer.prepare(); + } else { + events.onError("VideoError", "Video player had error " + error, null); + } + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + events.onIsPlayingStateUpdate(isPlaying); + } +} 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 2d56b2348068..7b3e44a1dcb5 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 @@ -17,11 +17,7 @@ import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; import androidx.media3.common.PlaybackParameters; -import androidx.media3.common.Player; -import androidx.media3.common.Player.Listener; -import androidx.media3.common.VideoSize; import androidx.media3.common.util.UnstableApi; import androidx.media3.datasource.DataSource; import androidx.media3.datasource.DefaultDataSource; @@ -47,8 +43,6 @@ final class VideoPlayer { private static final String USER_AGENT = "User-Agent"; - @VisibleForTesting boolean isInitialized = false; - private final VideoPlayerOptions options; private final DefaultHttpDataSource.Factory httpDataSourceFactory; @@ -116,61 +110,7 @@ private void setUpVideoPlayer(ExoPlayer exoPlayer) { surface = new Surface(textureEntry.surfaceTexture()); exoPlayer.setVideoSurface(surface); setAudioAttributes(exoPlayer, options.mixWithOthers); - - // Avoids synthetic accessor. - VideoPlayerCallbacks events = this.videoPlayerEvents; - - exoPlayer.addListener( - new Listener() { - private boolean isBuffering = false; - - public void setBuffering(boolean buffering) { - if (isBuffering == buffering) { - return; - } - isBuffering = buffering; - if (buffering) { - events.onBufferingStart(); - } else { - events.onBufferingEnd(); - } - } - - @Override - public void onPlaybackStateChanged(final int playbackState) { - if (playbackState == Player.STATE_BUFFERING) { - setBuffering(true); - sendBufferingUpdate(); - } else if (playbackState == Player.STATE_READY) { - if (!isInitialized) { - isInitialized = true; - sendInitialized(); - } - } else if (playbackState == Player.STATE_ENDED) { - events.onCompleted(); - } - if (playbackState != Player.STATE_BUFFERING) { - setBuffering(false); - } - } - - @Override - public void onPlayerError(@NonNull final PlaybackException error) { - setBuffering(false); - if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { - // See https://exoplayer.dev/live-streaming.html#behindlivewindowexception-and-error_code_behind_live_window - exoPlayer.seekToDefaultPosition(); - exoPlayer.prepare(); - } else { - events.onError("VideoError", "Video player had error " + error, null); - } - } - - @Override - public void onIsPlayingChanged(boolean isPlaying) { - events.onIsPlayingStateUpdate(isPlaying); - } - }); + exoPlayer.addListener(new ExoPlayerEventListener(exoPlayer, videoPlayerEvents)); } void sendBufferingUpdate() { @@ -216,38 +156,7 @@ long getPosition() { return exoPlayer.getCurrentPosition(); } - @SuppressWarnings("SuspiciousNameCombination") - @VisibleForTesting - void sendInitialized() { - if (!isInitialized) { - return; - } - VideoSize videoSize = exoPlayer.getVideoSize(); - int rotationCorrection = 0; - int width = videoSize.width; - int height = videoSize.height; - if (width != 0 && height != 0) { - int rotationDegrees = videoSize.unappliedRotationDegrees; - // Switch the width/height if video was taken in portrait mode - if (rotationDegrees == 90 || rotationDegrees == 270) { - width = videoSize.height; - height = videoSize.width; - } - // Rotating the video with ExoPlayer does not seem to be possible with a Surface, - // so inform the Flutter code that the widget needs to be rotated to prevent - // upside-down playback for videos with rotationDegrees of 180 (other orientations work - // correctly without correction). - if (rotationDegrees == 180) { - rotationCorrection = rotationDegrees; - } - } - videoPlayerEvents.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection); - } - void dispose() { - if (isInitialized) { - exoPlayer.stop(); - } textureEntry.release(); if (surface != null) { surface.release(); 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 bc9041eedd11..72bff5cd18eb 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 @@ -8,8 +8,10 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import io.flutter.plugin.common.EventChannel; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; final class VideoPlayerEventCallbacks implements VideoPlayerCallbacks { @@ -66,7 +68,10 @@ public void onBufferingStart() { public void onBufferingUpdate(long bufferedPosition) { // iOS supports a list of buffered ranges, so we send as a list with a single range. Map event = new HashMap<>(); - event.put("values", Collections.singletonList(bufferedPosition)); + event.put("event", "bufferingUpdate"); + + List range = Arrays.asList(0, bufferedPosition); + event.put("values", Collections.singletonList(range)); eventSink.success(event); } 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 72def743d518..d854601ff3fa 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,6 +5,7 @@ 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.*; @@ -20,10 +21,13 @@ 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.Test; @@ -130,22 +134,59 @@ public void videoPlayer_buildsHttpDataSourceFactoryProperlyWhenHttpHeadersNull() 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; + } + } + + fail("No 'bufferingUpdate' event found: " + events); + } + @Test public void sendInitializedSendsExpectedEvent_90RotationDegrees() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); + Player.Listener listener = initVideoPlayerAndGetListener(); VideoSize testVideoSize = new VideoSize(100, 200, 90, 1f); when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); when(fakeExoPlayer.getDuration()).thenReturn(10L); - videoPlayer.isInitialized = true; - videoPlayer.sendInitialized(); + // Send Player.STATE_READY to trigger the "initialized" event. + listener.onPlaybackStateChanged(Player.STATE_READY); verify(fakeEventSink).success(eventCaptor.capture()); HashMap actual = eventCaptor.getValue(); @@ -161,20 +202,14 @@ public void sendInitializedSendsExpectedEvent_90RotationDegrees() { @Test public void sendInitializedSendsExpectedEvent_270RotationDegrees() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); + Player.Listener listener = initVideoPlayerAndGetListener(); VideoSize testVideoSize = new VideoSize(100, 200, 270, 1f); when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); when(fakeExoPlayer.getDuration()).thenReturn(10L); - videoPlayer.isInitialized = true; - videoPlayer.sendInitialized(); + // Send Player.STATE_READY to trigger the "initialized" event. + listener.onPlaybackStateChanged(Player.STATE_READY); verify(fakeEventSink).success(eventCaptor.capture()); HashMap actual = eventCaptor.getValue(); @@ -190,20 +225,14 @@ public void sendInitializedSendsExpectedEvent_270RotationDegrees() { @Test public void sendInitializedSendsExpectedEvent_0RotationDegrees() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); + Player.Listener listener = initVideoPlayerAndGetListener(); VideoSize testVideoSize = new VideoSize(100, 200, 0, 1f); when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); when(fakeExoPlayer.getDuration()).thenReturn(10L); - videoPlayer.isInitialized = true; - videoPlayer.sendInitialized(); + // Send Player.STATE_READY to trigger the "initialized" event. + listener.onPlaybackStateChanged(Player.STATE_READY); verify(fakeEventSink).success(eventCaptor.capture()); HashMap actual = eventCaptor.getValue(); @@ -219,20 +248,14 @@ public void sendInitializedSendsExpectedEvent_0RotationDegrees() { @Test public void sendInitializedSendsExpectedEvent_180RotationDegrees() { - VideoPlayer videoPlayer = - new VideoPlayer( - fakeExoPlayer, - VideoPlayerEventCallbacks.withSink(fakeEventSink), - fakeSurfaceTextureEntry, - fakeVideoPlayerOptions, - httpDataSourceFactorySpy); + Player.Listener listener = initVideoPlayerAndGetListener(); VideoSize testVideoSize = new VideoSize(100, 200, 180, 1f); when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize); when(fakeExoPlayer.getDuration()).thenReturn(10L); - videoPlayer.isInitialized = true; - videoPlayer.sendInitialized(); + // Send Player.STATE_READY to trigger the "initialized" event. + listener.onPlaybackStateChanged(Player.STATE_READY); verify(fakeEventSink).success(eventCaptor.capture()); HashMap actual = eventCaptor.getValue();