Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/video_player/video_player_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
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,64 @@ 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.
*/
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.
*/
private 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,119 @@
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());
}
}
2 changes: 1 addition & 1 deletion packages/video_player/video_player_android/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down