Skip to content
1 change: 1 addition & 0 deletions packages/video_player/video_player_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## 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.
* Migrates to Built-in Kotlin to support AGP 9.
* Updates minimum supported SDK version to Flutter 3.44/Dart 3.12.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}

Expand All @@ -57,24 +49,63 @@ 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());
forceFirstFrameForAndroid9(exoPlayer);
}

@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. */
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.
*/
static void forceFirstFrameForAndroid9(@NonNull ExoPlayer exoPlayer) {
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P && !exoPlayer.getPlayWhenReady()) {
long position = exoPlayer.getCurrentPosition();
exoPlayer.seekTo(position == 0 ? 1 : position);
}
}

/**
* A custom SurfaceView that re-attaches the player surface when the view becomes visible again,
* such as after returning from a full-screen route transition.
*/
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, re-attach the current surface.
if (visibility == View.VISIBLE && isShown()) {
bindPlayerToSurface(exoPlayer, getHolder().getSurface());
}
}
}

/**
* Returns the view associated with this PlatformView.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,118 @@
import static org.mockito.Mockito.*;

import android.content.Context;
import android.os.Build;
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.annotation.Config;
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 {
@Config(sdk = 34)
public void surfaceCreatedBindsSurfaceWithoutSeekOutsideAndroid9() throws Exception {
final Context context = ApplicationProvider.getApplicationContext();
final ExoPlayer exoPlayer = spy(new ExoPlayer.Builder(context).build());
final ExoPlayer exoPlayer = mock(ExoPlayer.class);

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);

// Bypass FakeSurfaceHolder to get the callback registered by PlatformVideoView
ShadowSurfaceView shadowView = Shadows.shadowOf(surfaceView);
Iterable<SurfaceHolder.Callback> 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);
verify(exoPlayer, never()).seekTo(anyLong());
}

@Test
@Config(sdk = Build.VERSION_CODES.P)
public void surfaceCreatedSeeksOnAndroid9() throws Exception {
final Context context = ApplicationProvider.getApplicationContext();
final ExoPlayer exoPlayer = mock(ExoPlayer.class);
final PlatformVideoView view = new PlatformVideoView(context, exoPlayer);

final Field field = PlatformVideoView.class.getDeclaredField("surfaceView");
field.setAccessible(true);
final SurfaceView surfaceView = (SurfaceView) field.get(view);

assertNotNull(surfaceView);
verify(exoPlayer).setVideoSurfaceView(surfaceView);
ShadowSurfaceView shadowView = Shadows.shadowOf(surfaceView);
Iterable<SurfaceHolder.Callback> 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);
when(exoPlayer.getPlayWhenReady()).thenReturn(false);
when(exoPlayer.getCurrentPosition()).thenReturn(0L);

callback.surfaceCreated(mockHolder);

verify(exoPlayer).setVideoSurface(mockSurface);
verify(exoPlayer).seekTo(1);
}

@Test
@Config(sdk = 34)
public void rebindsSurfaceWhenVisibilityChangesToVisible() throws Exception {
final Context context = ApplicationProvider.getApplicationContext();
final ExoPlayer exoPlayer = mock(ExoPlayer.class);
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);
method.invoke(surfaceView, surfaceView, View.VISIBLE);

exoPlayer.release();
verify(exoPlayer).setVideoSurface(mockSurface);
verify(exoPlayer, never()).seekTo(anyLong());
}
}