Skip to content

Commit 5e03bb1

Browse files
authored
[video_player_android] Correct rotation of videos recorded by the camera (#7846)
Hopefully � corrects the calculation used to determine the rotation correction of the video being played. These are the different cases this addresses: 1. **Strictly below Android API 22:** Kept this calculation the same as before because I was not able to test it. For context, it uses an unapplied rotation degrees reported by the video's size to correct the rotation. Note that this is always 0 after Android API 22+ (see [its docs](https://github.com/google/ExoPlayer/blob/dd430f7053a1a3958deea3ead6a0565150c06bfc/library/common/src/main/java/com/google/android/exoplayer2/video/VideoSize.java#L65)) 2. **Above Android API 22, strictly below Android API 29:** The `SurfaceTexture` Impeller backend is used. From my testing, I see the preview is correctly rotated and sized without any further intervention, so the correction is set 0. 3. **Android API 29+:** The `ImageReader` Impelled backend is used and a preview correction was noticed by the community (see flutter/flutter#154696). To fix this, we now use the rotation correction reported by the video's format. We also now use this rotation to make the fix for a swapped = width and height when the correction is 90 or 270 degrees (indicating that the video is landscape) as the logic did before but instead with the unapplied rotation degrees (see case 1 for context). Tested this on Android APIs 28, 30, 32, and 35. A fix attempt for flutter/flutter#154696.
1 parent aad3fd0 commit 5e03bb1

File tree

5 files changed

+199
-17
lines changed

5 files changed

+199
-17
lines changed

packages/video_player/video_player_android/CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
## 2.7.15
2+
3+
* Changes the rotation correction calculation for Android API 29+ to use
4+
the one that is reported by the video's format instead of the unapplied
5+
rotation degrees that Exoplayer does not report on Android API 21+.
6+
* Changes the rotation correction calculation for Android APIs 21-28 to 0
7+
because the Impeller backend used on those API versions correctly rotates
8+
the video being played automatically.
9+
110
## 2.7.14
211

312
* Removes SSL workaround for API 19, which is no longer supported.
4-
*
13+
514
## 2.7.13
615

716
* When `AndroidVideoPlayer` attempts to operate on a `textureId` that is not

packages/video_player/video_player_android/android/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ android {
5757
unitTests.includeAndroidResources = true
5858
unitTests.returnDefaultValues = true
5959
unitTests.all {
60+
// The org.gradle.jvmargs property that may be set in gradle.properties does not impact
61+
// the Java heap size when running the Android unit tests. The following property here
62+
// sets the heap size to a size large enough to run the robolectric tests across
63+
// multiple SDK levels.
64+
jvmArgs "-Xmx1g"
6065
testLogging {
6166
events "passed", "skipped", "failed", "standardOut", "standardError"
6267
outputs.upToDateWhen {false}

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

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,48 @@
44

55
package io.flutter.plugins.videoplayer;
66

7+
import android.os.Build;
78
import androidx.annotation.NonNull;
9+
import androidx.annotation.OptIn;
10+
import androidx.media3.common.Format;
811
import androidx.media3.common.PlaybackException;
912
import androidx.media3.common.Player;
1013
import androidx.media3.common.VideoSize;
1114
import androidx.media3.exoplayer.ExoPlayer;
15+
import java.util.Objects;
1216

1317
final class ExoPlayerEventListener implements Player.Listener {
1418
private final ExoPlayer exoPlayer;
1519
private final VideoPlayerCallbacks events;
1620
private boolean isBuffering = false;
1721
private boolean isInitialized;
1822

23+
private enum RotationDegrees {
24+
ROTATE_0(0),
25+
ROTATE_90(90),
26+
ROTATE_180(180),
27+
ROTATE_270(270);
28+
29+
private final int degrees;
30+
31+
RotationDegrees(int degrees) {
32+
this.degrees = degrees;
33+
}
34+
35+
public static RotationDegrees fromDegrees(int degrees) {
36+
for (RotationDegrees rotationDegrees : RotationDegrees.values()) {
37+
if (rotationDegrees.degrees == degrees) {
38+
return rotationDegrees;
39+
}
40+
}
41+
throw new IllegalArgumentException("Invalid rotation degrees specified: " + degrees);
42+
}
43+
44+
public int getDegrees() {
45+
return this.degrees;
46+
}
47+
}
48+
1949
ExoPlayerEventListener(ExoPlayer exoPlayer, VideoPlayerCallbacks events) {
2050
this(exoPlayer, events, false);
2151
}
@@ -49,23 +79,80 @@ private void sendInitialized() {
4979
int width = videoSize.width;
5080
int height = videoSize.height;
5181
if (width != 0 && height != 0) {
52-
int rotationDegrees = videoSize.unappliedRotationDegrees;
53-
// Switch the width/height if video was taken in portrait mode
54-
if (rotationDegrees == 90 || rotationDegrees == 270) {
82+
RotationDegrees reportedRotationCorrection = RotationDegrees.ROTATE_0;
83+
84+
if (Build.VERSION.SDK_INT <= 21) {
85+
// On API 21 and below, Exoplayer may not internally handle rotation correction
86+
// and reports it through VideoSize.unappliedRotationDegrees. We may apply it to
87+
// fix the case of upside-down playback.
88+
try {
89+
reportedRotationCorrection =
90+
RotationDegrees.fromDegrees(videoSize.unappliedRotationDegrees);
91+
rotationCorrection =
92+
getRotationCorrectionFromUnappliedRotation(reportedRotationCorrection);
93+
} catch (IllegalArgumentException e) {
94+
// Unapplied rotation other than 0, 90, 180, 270 reported by VideoSize. Because this is unexpected,
95+
// we apply no rotation correction.
96+
reportedRotationCorrection = RotationDegrees.ROTATE_0;
97+
rotationCorrection = 0;
98+
}
99+
}
100+
// TODO(camsim99): Replace this with a call to `handlesCropAndRotation` when it is
101+
// available in stable. https://github.com/flutter/flutter/issues/157198
102+
else if (Build.VERSION.SDK_INT < 29) {
103+
// When the SurfaceTexture backend for Impeller is used, the preview should already
104+
// be correctly rotated.
105+
rotationCorrection = 0;
106+
} else {
107+
// The video's Format also provides a rotation correction that may be used to
108+
// correct the rotation, so we try to use that to correct the video rotation
109+
// when the ImageReader backend for Impeller is used.
110+
rotationCorrection = getRotationCorrectionFromFormat(exoPlayer);
111+
112+
try {
113+
reportedRotationCorrection = RotationDegrees.fromDegrees(rotationCorrection);
114+
} catch (IllegalArgumentException e) {
115+
// Rotation correction other than 0, 90, 180, 270 reported by Format. Because this is unexpected,
116+
// we apply no rotation correction.
117+
reportedRotationCorrection = RotationDegrees.ROTATE_0;
118+
rotationCorrection = 0;
119+
}
120+
}
121+
122+
// Switch the width/height if video was taken in portrait mode and a rotation
123+
// correction was detected.
124+
if (reportedRotationCorrection == RotationDegrees.ROTATE_90
125+
|| reportedRotationCorrection == RotationDegrees.ROTATE_270) {
55126
width = videoSize.height;
56127
height = videoSize.width;
57128
}
58-
// Rotating the video with ExoPlayer does not seem to be possible with a Surface,
59-
// so inform the Flutter code that the widget needs to be rotated to prevent
60-
// upside-down playback for videos with rotationDegrees of 180 (other orientations work
61-
// correctly without correction).
62-
if (rotationDegrees == 180) {
63-
rotationCorrection = rotationDegrees;
64-
}
65129
}
66130
events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection);
67131
}
68132

133+
private int getRotationCorrectionFromUnappliedRotation(RotationDegrees unappliedRotationDegrees) {
134+
int rotationCorrection = 0;
135+
136+
// Rotating the video with ExoPlayer does not seem to be possible with a Surface,
137+
// so inform the Flutter code that the widget needs to be rotated to prevent
138+
// upside-down playback for videos with unappliedRotationDegrees of 180 (other orientations
139+
// work correctly without correction).
140+
if (unappliedRotationDegrees == RotationDegrees.ROTATE_180) {
141+
rotationCorrection = unappliedRotationDegrees.getDegrees();
142+
}
143+
144+
return rotationCorrection;
145+
}
146+
147+
@OptIn(markerClass = androidx.media3.common.util.UnstableApi.class)
148+
// A video's Format and its rotation degrees are unstable because they are not guaranteed
149+
// the same implementation across API versions. It is possible that this logic may need
150+
// revisiting should the implementation change across versions of the Exoplayer API.
151+
private int getRotationCorrectionFromFormat(ExoPlayer exoPlayer) {
152+
Format videoFormat = Objects.requireNonNull(exoPlayer.getVideoFormat());
153+
return videoFormat.rotationDegrees;
154+
}
155+
69156
@Override
70157
public void onPlaybackStateChanged(final int playbackState) {
71158
switch (playbackState) {
Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import static org.mockito.Mockito.verifyNoMoreInteractions;
1313
import static org.mockito.Mockito.when;
1414

15+
import androidx.media3.common.Format;
1516
import androidx.media3.common.PlaybackException;
1617
import androidx.media3.common.Player;
1718
import androidx.media3.common.VideoSize;
@@ -24,6 +25,7 @@
2425
import org.mockito.junit.MockitoJUnit;
2526
import org.mockito.junit.MockitoRule;
2627
import org.robolectric.RobolectricTestRunner;
28+
import org.robolectric.annotation.Config;
2729

2830
/**
2931
* Unit tests for {@link ExoPlayerEventListener}.
@@ -33,7 +35,7 @@
3335
* ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected.
3436
*/
3537
@RunWith(RobolectricTestRunner.class)
36-
public final class ExoPlayerEventListenerTests {
38+
public final class ExoPlayerEventListenerTest {
3739
@Mock private ExoPlayer mockExoPlayer;
3840
@Mock private VideoPlayerCallbacks mockCallbacks;
3941
private ExoPlayerEventListener eventListener;
@@ -46,7 +48,8 @@ public void setUp() {
4648
}
4749

4850
@Test
49-
public void onPlaybackStateChangedReadySendInitialized() {
51+
@Config(maxSdk = 28)
52+
public void onPlaybackStateChangedReadySendInitialized_belowAndroid29() {
5053
VideoSize size = new VideoSize(800, 400, 0, 0);
5154
when(mockExoPlayer.getVideoSize()).thenReturn(size);
5255
when(mockExoPlayer.getDuration()).thenReturn(10L);
@@ -56,7 +59,25 @@ public void onPlaybackStateChangedReadySendInitialized() {
5659
}
5760

5861
@Test
59-
public void onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight() {
62+
@Config(minSdk = 29)
63+
public void
64+
onPlaybackStateChangedReadySendInitializedWithRotationCorrectionAndWidthAndHeightSwap_aboveAndroid29() {
65+
VideoSize size = new VideoSize(800, 400, 0, 0);
66+
int rotationCorrection = 90;
67+
Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build();
68+
69+
when(mockExoPlayer.getVideoSize()).thenReturn(size);
70+
when(mockExoPlayer.getDuration()).thenReturn(10L);
71+
when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat);
72+
73+
eventListener.onPlaybackStateChanged(Player.STATE_READY);
74+
verify(mockCallbacks).onInitialized(400, 800, 10L, rotationCorrection);
75+
}
76+
77+
@Test
78+
@Config(maxSdk = 21)
79+
public void
80+
onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight_belowAndroid21() {
6081
VideoSize size = new VideoSize(800, 400, 90, 0);
6182
when(mockExoPlayer.getVideoSize()).thenReturn(size);
6283
when(mockExoPlayer.getDuration()).thenReturn(10L);
@@ -66,7 +87,38 @@ public void onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight
6687
}
6788

6889
@Test
69-
public void onPlaybackStateChangedReadyInPortraitMode270DegreesSwapWidthAndHeight() {
90+
@Config(minSdk = 22, maxSdk = 28)
91+
public void
92+
onPlaybackStateChangedReadyInPortraitMode90DegreesDoesNotSwapWidthAndHeight_aboveAndroid21belowAndroid29() {
93+
VideoSize size = new VideoSize(800, 400, 90, 0);
94+
95+
when(mockExoPlayer.getVideoSize()).thenReturn(size);
96+
when(mockExoPlayer.getDuration()).thenReturn(10L);
97+
98+
eventListener.onPlaybackStateChanged(Player.STATE_READY);
99+
verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
100+
}
101+
102+
@Test
103+
@Config(minSdk = 29)
104+
public void
105+
onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight_aboveAndroid29() {
106+
VideoSize size = new VideoSize(800, 400, 0, 0);
107+
int rotationCorrection = 90;
108+
Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build();
109+
110+
when(mockExoPlayer.getVideoSize()).thenReturn(size);
111+
when(mockExoPlayer.getDuration()).thenReturn(10L);
112+
when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat);
113+
114+
eventListener.onPlaybackStateChanged(Player.STATE_READY);
115+
verify(mockCallbacks).onInitialized(400, 800, 10L, 90);
116+
}
117+
118+
@Test
119+
@Config(maxSdk = 21)
120+
public void
121+
onPlaybackStateChangedReadyInPortraitMode270DegreesSwapWidthAndHeight_belowAndroid21() {
70122
VideoSize size = new VideoSize(800, 400, 270, 0);
71123
when(mockExoPlayer.getVideoSize()).thenReturn(size);
72124
when(mockExoPlayer.getDuration()).thenReturn(10L);
@@ -76,7 +128,36 @@ public void onPlaybackStateChangedReadyInPortraitMode270DegreesSwapWidthAndHeigh
76128
}
77129

78130
@Test
79-
public void onPlaybackStateChangedReadyFlipped180DegreesInformEventHandler() {
131+
@Config(minSdk = 22, maxSdk = 28)
132+
public void
133+
onPlaybackStateChangedReadyInPortraitMode270DegreesDoesNotSwapWidthAndHeight_aboveAndroid21belowAndroid29() {
134+
VideoSize size = new VideoSize(800, 400, 270, 0);
135+
when(mockExoPlayer.getVideoSize()).thenReturn(size);
136+
when(mockExoPlayer.getDuration()).thenReturn(10L);
137+
138+
eventListener.onPlaybackStateChanged(Player.STATE_READY);
139+
verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
140+
}
141+
142+
@Test
143+
@Config(minSdk = 29)
144+
public void
145+
onPlaybackStateChangedReadyInPortraitMode270DegreesSwapWidthAndHeight_aboveAndroid29() {
146+
VideoSize size = new VideoSize(800, 400, 0, 0);
147+
int rotationCorrection = 270;
148+
Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build();
149+
150+
when(mockExoPlayer.getVideoSize()).thenReturn(size);
151+
when(mockExoPlayer.getDuration()).thenReturn(10L);
152+
when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat);
153+
154+
eventListener.onPlaybackStateChanged(Player.STATE_READY);
155+
verify(mockCallbacks).onInitialized(400, 800, 10L, 270);
156+
}
157+
158+
@Test
159+
@Config(maxSdk = 21)
160+
public void onPlaybackStateChangedReadyFlipped180DegreesInformEventHandler_belowAndroid21() {
80161
VideoSize size = new VideoSize(800, 400, 180, 0);
81162
when(mockExoPlayer.getVideoSize()).thenReturn(size);
82163
when(mockExoPlayer.getDuration()).thenReturn(10L);

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.7.14
5+
version: 2.7.15
66

77
environment:
88
sdk: ^3.5.0

0 commit comments

Comments
 (0)