diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md index 85f40631a278..1cbe86190878 100644 --- a/packages/video_player/video_player_android/CHANGELOG.md +++ b/packages/video_player/video_player_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.9.6 + +* Fixes a [bug](https://github.com/flutter/flutter/issues/184241) where the video freezes after returning from a full-screen transition on Android. + ## 2.9.5 * Updates build files from Groovy to Kotlin. diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java index 35fd66283231..d528d1c3ea29 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java @@ -6,6 +6,7 @@ import android.content.Context; import android.os.Build; +import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.View; @@ -30,23 +31,14 @@ public final class PlatformVideoView implements PlatformView { */ @OptIn(markerClass = UnstableApi.class) public PlatformVideoView(@NonNull Context context, @NonNull ExoPlayer exoPlayer) { - surfaceView = new SurfaceView(context); - - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { - // Workaround for rendering issues on Android 9 (API 28). - // On Android 9, using setVideoSurfaceView seems to lead to issues where the first frame is - // not displayed if the video is paused initially. - // To ensure the first frame is visible, the surface is directly set using holder.getSurface() - // when the surface is created, and ExoPlayer seeks to a position to force rendering of the - // first frame. - setupSurfaceWithCallback(exoPlayer); - } else { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) { - // Avoid blank space instead of a video on Android versions below 8 by adjusting video's - // z-layer within the Android view hierarchy: - surfaceView.setZOrderMediaOverlay(true); - } - exoPlayer.setVideoSurfaceView(surfaceView); + this.surfaceView = new VideoSurfaceView(context, exoPlayer); + + setupSurfaceWithCallback(exoPlayer); + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) { + // Avoid blank space instead of a video on Android versions below 8 by adjusting video's + // z-layer within the Android view hierarchy: + surfaceView.setZOrderMediaOverlay(true); } } @@ -57,24 +49,65 @@ private void setupSurfaceWithCallback(@NonNull ExoPlayer exoPlayer) { new SurfaceHolder.Callback() { @Override public void surfaceCreated(@NonNull SurfaceHolder holder) { - exoPlayer.setVideoSurface(holder.getSurface()); - // Force first frame rendering: - exoPlayer.seekTo(1); + bindPlayerToSurface(exoPlayer, holder.getSurface()); } @Override public void surfaceChanged( - @NonNull SurfaceHolder holder, int format, int width, int height) { - // No implementation needed. - } + @NonNull SurfaceHolder holder, int format, int width, int height) {} @Override public void surfaceDestroyed(@NonNull SurfaceHolder holder) { - exoPlayer.setVideoSurface(null); + // Use clearVideoSurface to ensure we only unbind if this surface is currently active. + exoPlayer.clearVideoSurface(holder.getSurface()); } }); } + /** + * Binds the ExoPlayer to the provided surface and performs a seek operation to ensure the video + * frame is rendered. Includes special handling for Android 9. + */ + private static void bindPlayerToSurface(@NonNull ExoPlayer exoPlayer, @NonNull Surface surface) { + if (surface.isValid()) { + exoPlayer.setVideoSurface(surface); + + // Workaround for a rendering bug on Android 9 (API 28) where the decoder does not + // flush its output buffer when a new surface is attached while the player is paused, + // resulting in a black frame. A seek forces the codec to produce and display a frame. + if (!exoPlayer.getPlayWhenReady()) { + long current = exoPlayer.getCurrentPosition(); + if (current == 0 && Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { + exoPlayer.seekTo(1); + } else { + exoPlayer.seekTo(current); + } + } + } + } + + /** + * A custom SurfaceView that handles visibility changes to ensure video rendering is restored + * during route transitions (e.g., returning from a full-screen view). + */ + private static class VideoSurfaceView extends SurfaceView { + private final ExoPlayer exoPlayer; + + public VideoSurfaceView(Context context, ExoPlayer exoPlayer) { + super(context); + this.exoPlayer = exoPlayer; + } + + @Override + protected void onVisibilityChanged(@NonNull View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + // When the view becomes visible again, ensure the ExoPlayer is re-attached to the surface. + if (visibility == View.VISIBLE && isShown()) { + bindPlayerToSurface(exoPlayer, getHolder().getSurface()); + } + } + } + /** * Returns the view associated with this PlatformView. * diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java index 3beab4258f2f..902b9c6be345 100644 --- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java @@ -8,18 +8,27 @@ import static org.mockito.Mockito.*; import android.content.Context; +import android.view.Surface; +import android.view.SurfaceHolder; import android.view.SurfaceView; +import android.view.View; import androidx.media3.exoplayer.ExoPlayer; import androidx.test.core.app.ApplicationProvider; import io.flutter.plugins.videoplayer.platformview.PlatformVideoView; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Objects; + import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; +import org.robolectric.shadows.ShadowSurfaceView; -/** Unit tests for {@link PlatformVideoViewTest}. */ +/** Unit tests for {@link PlatformVideoView}. */ @RunWith(RobolectricTestRunner.class) public class PlatformVideoViewTest { + @Test public void createsSurfaceViewAndSetsItForExoPlayer() throws Exception { final Context context = ApplicationProvider.getApplicationContext(); @@ -27,12 +36,63 @@ public void createsSurfaceViewAndSetsItForExoPlayer() throws Exception { final PlatformVideoView view = new PlatformVideoView(context, exoPlayer); + // Get the internal SurfaceView via reflection for testing. final Field field = PlatformVideoView.class.getDeclaredField("surfaceView"); field.setAccessible(true); final SurfaceView surfaceView = (SurfaceView) field.get(view); - assertNotNull(surfaceView); - verify(exoPlayer).setVideoSurfaceView(surfaceView); + // Bypass FakeSurfaceHolder to get the callback registered by PlatformVideoView + ShadowSurfaceView shadowView = Shadows.shadowOf(surfaceView); + Iterable callbacks = shadowView.getFakeSurfaceHolder().getCallbacks(); + assertNotNull("SurfaceCallbacks should not be null", callbacks); + + SurfaceHolder.Callback callback = callbacks.iterator().next(); + assertNotNull("Callback must exist", callback); + + Surface mockSurface = mock(Surface.class); + when(mockSurface.isValid()).thenReturn(true); + SurfaceHolder mockHolder = mock(SurfaceHolder.class); + when(mockHolder.getSurface()).thenReturn(mockSurface); + + // Trigger manually + callback.surfaceCreated(mockHolder); + + // Verify it used the manual surface mechanism instead of setVideoSurfaceView() + verify(exoPlayer).setVideoSurface(mockSurface); + + // For Android 9 bug workaround + verify(exoPlayer).seekTo(anyLong()); + + exoPlayer.release(); + } + + @Test + public void rebindsSurfaceWhenVisibilityChangesToVisible() throws Exception { + final Context context = ApplicationProvider.getApplicationContext(); + final ExoPlayer exoPlayer = spy(new ExoPlayer.Builder(context).build()); + final PlatformVideoView view = new PlatformVideoView(context, exoPlayer); + + final Field field = PlatformVideoView.class.getDeclaredField("surfaceView"); + field.setAccessible(true); + final SurfaceView surfaceView = spy((SurfaceView) Objects.requireNonNull(field.get(view))); + when(surfaceView.isShown()).thenReturn(true); + field.set(view, surfaceView); // Inject the spy back + + Surface mockSurface = mock(Surface.class); + when(mockSurface.isValid()).thenReturn(true); + SurfaceHolder mockHolder = mock(SurfaceHolder.class); + when(mockHolder.getSurface()).thenReturn(mockSurface); + when(surfaceView.getHolder()).thenReturn(mockHolder); + + // Trigger visibility changed + Method method = View.class.getDeclaredMethod("onVisibilityChanged", View.class, int.class); + method.setAccessible(true); + + reset(exoPlayer); + method.invoke(surfaceView, surfaceView, View.VISIBLE); + + verify(exoPlayer).setVideoSurface(mockSurface); + verify(exoPlayer).seekTo(anyLong()); exoPlayer.release(); } diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml index 4461ddd73d1e..d68d7507bae2 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.9.5 +version: 2.9.6 environment: sdk: ^3.9.0