Skip to content

Commit bb099a6

Browse files
committed
[video_player_android] Fix rendering freeze after full-screen transition (#184241)
1 parent 4763d4f commit bb099a6

File tree

4 files changed

+122
-28
lines changed

4 files changed

+122
-28
lines changed

packages/video_player/video_player_android/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.9.6
2+
3+
* Fixes a [bug](https://github.com/flutter/flutter/issues/184241) where the video freezes after returning from a full-screen transition on Android.
4+
15
## 2.9.5
26

37
* Updates build files from Groovy to Kotlin.

packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import android.content.Context;
88
import android.os.Build;
9+
import android.view.Surface;
910
import android.view.SurfaceHolder;
1011
import android.view.SurfaceView;
1112
import android.view.View;
@@ -30,23 +31,14 @@ public final class PlatformVideoView implements PlatformView {
3031
*/
3132
@OptIn(markerClass = UnstableApi.class)
3233
public PlatformVideoView(@NonNull Context context, @NonNull ExoPlayer exoPlayer) {
33-
surfaceView = new SurfaceView(context);
34-
35-
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
36-
// Workaround for rendering issues on Android 9 (API 28).
37-
// On Android 9, using setVideoSurfaceView seems to lead to issues where the first frame is
38-
// not displayed if the video is paused initially.
39-
// To ensure the first frame is visible, the surface is directly set using holder.getSurface()
40-
// when the surface is created, and ExoPlayer seeks to a position to force rendering of the
41-
// first frame.
42-
setupSurfaceWithCallback(exoPlayer);
43-
} else {
44-
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) {
45-
// Avoid blank space instead of a video on Android versions below 8 by adjusting video's
46-
// z-layer within the Android view hierarchy:
47-
surfaceView.setZOrderMediaOverlay(true);
48-
}
49-
exoPlayer.setVideoSurfaceView(surfaceView);
34+
this.surfaceView = new VideoSurfaceView(context, exoPlayer);
35+
36+
setupSurfaceWithCallback(exoPlayer);
37+
38+
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) {
39+
// Avoid blank space instead of a video on Android versions below 8 by adjusting video's
40+
// z-layer within the Android view hierarchy:
41+
surfaceView.setZOrderMediaOverlay(true);
5042
}
5143
}
5244

@@ -57,24 +49,63 @@ private void setupSurfaceWithCallback(@NonNull ExoPlayer exoPlayer) {
5749
new SurfaceHolder.Callback() {
5850
@Override
5951
public void surfaceCreated(@NonNull SurfaceHolder holder) {
60-
exoPlayer.setVideoSurface(holder.getSurface());
61-
// Force first frame rendering:
62-
exoPlayer.seekTo(1);
52+
bindPlayerToSurface(exoPlayer, holder.getSurface());
6353
}
6454

6555
@Override
6656
public void surfaceChanged(
67-
@NonNull SurfaceHolder holder, int format, int width, int height) {
68-
// No implementation needed.
69-
}
57+
@NonNull SurfaceHolder holder, int format, int width, int height) {}
7058

7159
@Override
7260
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
73-
exoPlayer.setVideoSurface(null);
61+
// Use clearVideoSurface to ensure we only unbind if this surface is currently active.
62+
exoPlayer.clearVideoSurface(holder.getSurface());
7463
}
7564
});
7665
}
7766

67+
/**
68+
* Binds the ExoPlayer to the provided surface and performs a seek operation to ensure the video
69+
* frame is rendered. Includes special handling for Android 9.
70+
*/
71+
private static void bindPlayerToSurface(@NonNull ExoPlayer exoPlayer, @NonNull Surface surface) {
72+
if (surface.isValid()) {
73+
exoPlayer.setVideoSurface(surface);
74+
75+
// Workaround for a rendering bug on Android 9 (API 28) where the decoder does not
76+
// flush its output buffer when a new surface is attached while the player is paused,
77+
// resulting in a black frame. A seek forces the codec to produce and display a frame.
78+
long current = exoPlayer.getCurrentPosition();
79+
if (current == 0 && Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
80+
exoPlayer.seekTo(1);
81+
} else {
82+
exoPlayer.seekTo(current);
83+
}
84+
}
85+
}
86+
87+
/**
88+
* A custom SurfaceView that handles visibility changes to ensure video rendering is restored
89+
* during route transitions (e.g., returning from a full-screen view).
90+
*/
91+
private static class VideoSurfaceView extends SurfaceView {
92+
private final ExoPlayer exoPlayer;
93+
94+
public VideoSurfaceView(Context context, ExoPlayer exoPlayer) {
95+
super(context);
96+
this.exoPlayer = exoPlayer;
97+
}
98+
99+
@Override
100+
protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
101+
super.onVisibilityChanged(changedView, visibility);
102+
// When the view becomes visible again, ensure the ExoPlayer is re-attached to the surface.
103+
if (visibility == View.VISIBLE) {
104+
bindPlayerToSurface(exoPlayer, getHolder().getSurface());
105+
}
106+
}
107+
}
108+
78109
/**
79110
* Returns the view associated with this PlatformView.
80111
*

packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,90 @@
88
import static org.mockito.Mockito.*;
99

1010
import android.content.Context;
11+
import android.view.Surface;
12+
import android.view.SurfaceHolder;
1113
import android.view.SurfaceView;
14+
import android.view.View;
1215
import androidx.media3.exoplayer.ExoPlayer;
1316
import androidx.test.core.app.ApplicationProvider;
1417
import io.flutter.plugins.videoplayer.platformview.PlatformVideoView;
1518
import java.lang.reflect.Field;
19+
import java.lang.reflect.Method;
20+
import java.util.Objects;
21+
1622
import org.junit.Test;
1723
import org.junit.runner.RunWith;
1824
import org.robolectric.RobolectricTestRunner;
25+
import org.robolectric.Shadows;
26+
import org.robolectric.shadows.ShadowSurfaceView;
1927

20-
/** Unit tests for {@link PlatformVideoViewTest}. */
28+
/** Unit tests for {@link PlatformVideoView}. */
2129
@RunWith(RobolectricTestRunner.class)
2230
public class PlatformVideoViewTest {
31+
2332
@Test
2433
public void createsSurfaceViewAndSetsItForExoPlayer() throws Exception {
2534
final Context context = ApplicationProvider.getApplicationContext();
2635
final ExoPlayer exoPlayer = spy(new ExoPlayer.Builder(context).build());
2736

2837
final PlatformVideoView view = new PlatformVideoView(context, exoPlayer);
2938

39+
// Get the internal SurfaceView via reflection for testing.
3040
final Field field = PlatformVideoView.class.getDeclaredField("surfaceView");
3141
field.setAccessible(true);
3242
final SurfaceView surfaceView = (SurfaceView) field.get(view);
3343

34-
assertNotNull(surfaceView);
35-
verify(exoPlayer).setVideoSurfaceView(surfaceView);
44+
// Bypass FakeSurfaceHolder to get the callback registered by PlatformVideoView
45+
ShadowSurfaceView shadowView = Shadows.shadowOf(surfaceView);
46+
Iterable<SurfaceHolder.Callback> callbacks = shadowView.getFakeSurfaceHolder().getCallbacks();
47+
assertNotNull("SurfaceCallbacks should not be null", callbacks);
48+
49+
SurfaceHolder.Callback callback = callbacks.iterator().next();
50+
assertNotNull("Callback must exist", callback);
51+
52+
Surface mockSurface = mock(Surface.class);
53+
when(mockSurface.isValid()).thenReturn(true);
54+
SurfaceHolder mockHolder = mock(SurfaceHolder.class);
55+
when(mockHolder.getSurface()).thenReturn(mockSurface);
56+
57+
// Trigger manually
58+
callback.surfaceCreated(mockHolder);
59+
60+
// Verify it used the manual surface mechanism instead of setVideoSurfaceView()
61+
verify(exoPlayer).setVideoSurface(mockSurface);
62+
63+
// For Android 9 bug workaround
64+
verify(exoPlayer).seekTo(anyLong());
65+
66+
exoPlayer.release();
67+
}
68+
69+
@Test
70+
public void rebindsSurfaceWhenVisibilityChangesToVisible() throws Exception {
71+
final Context context = ApplicationProvider.getApplicationContext();
72+
final ExoPlayer exoPlayer = spy(new ExoPlayer.Builder(context).build());
73+
final PlatformVideoView view = new PlatformVideoView(context, exoPlayer);
74+
75+
final Field field = PlatformVideoView.class.getDeclaredField("surfaceView");
76+
field.setAccessible(true);
77+
final SurfaceView surfaceView = spy((SurfaceView) Objects.requireNonNull(field.get(view)));
78+
field.set(view, surfaceView); // Inject the spy back
79+
80+
Surface mockSurface = mock(Surface.class);
81+
when(mockSurface.isValid()).thenReturn(true);
82+
SurfaceHolder mockHolder = mock(SurfaceHolder.class);
83+
when(mockHolder.getSurface()).thenReturn(mockSurface);
84+
when(surfaceView.getHolder()).thenReturn(mockHolder);
85+
86+
// Trigger visibility changed
87+
Method method = View.class.getDeclaredMethod("onVisibilityChanged", View.class, int.class);
88+
method.setAccessible(true);
89+
90+
reset(exoPlayer);
91+
method.invoke(surfaceView, surfaceView, View.VISIBLE);
92+
93+
verify(exoPlayer).setVideoSurface(mockSurface);
94+
verify(exoPlayer).seekTo(anyLong());
3695

3796
exoPlayer.release();
3897
}

packages/video_player/video_player_android/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: video_player_android
22
description: Android implementation of the video_player plugin.
33
repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
5-
version: 2.9.5
5+
version: 2.9.6
66

77
environment:
88
sdk: ^3.9.0

0 commit comments

Comments
 (0)