mirror of
https://github.com/flutter/packages.git
synced 2025-07-03 09:08:54 +08:00
Move Player.Listener
impl, remove @VisibleForTesting isInitialized
. (#6922)
Similar to https://github.com/flutter/packages/pull/6908, as part of https://github.com/flutter/flutter/issues/148417. I'm working on re-landing https://github.com/flutter/packages/pull/6456, this time without using the `ActivityAware` interface (see https://github.com/flutter/flutter/issues/148417). As part of that work, I'll need to better control the `ExoPlayer` lifecycle and save/restore internal state. In this PR, I've removed the concept of the class being "initialized" or not - the only thing "initialized" means is "for a given instance of `ExoPlayer`, has received the `'initialized'` event. As a result I removed the quasi-public API that was used for testing only and replaced it with observing what the real production instance does (`Player.STATE_READY`). After this PR, I'll likely do one more pass around the constructors - the constructor that takes an `ExoPlayer` that is marked `@VisibleForTesting` _also_ doesn't make sense once we'll support suspending/resuming video players, so it will need to get reworked (probably into taking a factory method).
This commit is contained in:
@ -0,0 +1,101 @@
|
||||
// Copyright 2013 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
package io.flutter.plugins.videoplayer;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.VideoSize;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
|
||||
final class ExoPlayerEventListener implements Player.Listener {
|
||||
private final ExoPlayer exoPlayer;
|
||||
private final VideoPlayerCallbacks events;
|
||||
private boolean isBuffering = false;
|
||||
private boolean isInitialized = false;
|
||||
|
||||
ExoPlayerEventListener(ExoPlayer exoPlayer, VideoPlayerCallbacks events) {
|
||||
this.exoPlayer = exoPlayer;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
private void setBuffering(boolean buffering) {
|
||||
if (isBuffering == buffering) {
|
||||
return;
|
||||
}
|
||||
isBuffering = buffering;
|
||||
if (buffering) {
|
||||
events.onBufferingStart();
|
||||
} else {
|
||||
events.onBufferingEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("SuspiciousNameCombination")
|
||||
private void sendInitialized() {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
isInitialized = true;
|
||||
VideoSize videoSize = exoPlayer.getVideoSize();
|
||||
int rotationCorrection = 0;
|
||||
int width = videoSize.width;
|
||||
int height = videoSize.height;
|
||||
if (width != 0 && height != 0) {
|
||||
int rotationDegrees = videoSize.unappliedRotationDegrees;
|
||||
// Switch the width/height if video was taken in portrait mode
|
||||
if (rotationDegrees == 90 || rotationDegrees == 270) {
|
||||
width = videoSize.height;
|
||||
height = videoSize.width;
|
||||
}
|
||||
// Rotating the video with ExoPlayer does not seem to be possible with a Surface,
|
||||
// so inform the Flutter code that the widget needs to be rotated to prevent
|
||||
// upside-down playback for videos with rotationDegrees of 180 (other orientations work
|
||||
// correctly without correction).
|
||||
if (rotationDegrees == 180) {
|
||||
rotationCorrection = rotationDegrees;
|
||||
}
|
||||
}
|
||||
events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(final int playbackState) {
|
||||
switch (playbackState) {
|
||||
case Player.STATE_BUFFERING:
|
||||
setBuffering(true);
|
||||
events.onBufferingUpdate(exoPlayer.getBufferedPosition());
|
||||
break;
|
||||
case Player.STATE_READY:
|
||||
sendInitialized();
|
||||
break;
|
||||
case Player.STATE_ENDED:
|
||||
events.onCompleted();
|
||||
break;
|
||||
case Player.STATE_IDLE:
|
||||
break;
|
||||
}
|
||||
if (playbackState != Player.STATE_BUFFERING) {
|
||||
setBuffering(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(@NonNull final PlaybackException error) {
|
||||
setBuffering(false);
|
||||
if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) {
|
||||
// See https://exoplayer.dev/live-streaming.html#behindlivewindowexception-and-error_code_behind_live_window
|
||||
exoPlayer.seekToDefaultPosition();
|
||||
exoPlayer.prepare();
|
||||
} else {
|
||||
events.onError("VideoError", "Video player had error " + error, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIsPlayingChanged(boolean isPlaying) {
|
||||
events.onIsPlayingStateUpdate(isPlaying);
|
||||
}
|
||||
}
|
@ -17,11 +17,7 @@ import androidx.media3.common.AudioAttributes;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.PlaybackParameters;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.Player.Listener;
|
||||
import androidx.media3.common.VideoSize;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
@ -47,8 +43,6 @@ final class VideoPlayer {
|
||||
|
||||
private static final String USER_AGENT = "User-Agent";
|
||||
|
||||
@VisibleForTesting boolean isInitialized = false;
|
||||
|
||||
private final VideoPlayerOptions options;
|
||||
|
||||
private final DefaultHttpDataSource.Factory httpDataSourceFactory;
|
||||
@ -116,61 +110,7 @@ final class VideoPlayer {
|
||||
surface = new Surface(textureEntry.surfaceTexture());
|
||||
exoPlayer.setVideoSurface(surface);
|
||||
setAudioAttributes(exoPlayer, options.mixWithOthers);
|
||||
|
||||
// Avoids synthetic accessor.
|
||||
VideoPlayerCallbacks events = this.videoPlayerEvents;
|
||||
|
||||
exoPlayer.addListener(
|
||||
new Listener() {
|
||||
private boolean isBuffering = false;
|
||||
|
||||
public void setBuffering(boolean buffering) {
|
||||
if (isBuffering == buffering) {
|
||||
return;
|
||||
}
|
||||
isBuffering = buffering;
|
||||
if (buffering) {
|
||||
events.onBufferingStart();
|
||||
} else {
|
||||
events.onBufferingEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(final int playbackState) {
|
||||
if (playbackState == Player.STATE_BUFFERING) {
|
||||
setBuffering(true);
|
||||
sendBufferingUpdate();
|
||||
} else if (playbackState == Player.STATE_READY) {
|
||||
if (!isInitialized) {
|
||||
isInitialized = true;
|
||||
sendInitialized();
|
||||
}
|
||||
} else if (playbackState == Player.STATE_ENDED) {
|
||||
events.onCompleted();
|
||||
}
|
||||
if (playbackState != Player.STATE_BUFFERING) {
|
||||
setBuffering(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(@NonNull final PlaybackException error) {
|
||||
setBuffering(false);
|
||||
if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) {
|
||||
// See https://exoplayer.dev/live-streaming.html#behindlivewindowexception-and-error_code_behind_live_window
|
||||
exoPlayer.seekToDefaultPosition();
|
||||
exoPlayer.prepare();
|
||||
} else {
|
||||
events.onError("VideoError", "Video player had error " + error, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIsPlayingChanged(boolean isPlaying) {
|
||||
events.onIsPlayingStateUpdate(isPlaying);
|
||||
}
|
||||
});
|
||||
exoPlayer.addListener(new ExoPlayerEventListener(exoPlayer, videoPlayerEvents));
|
||||
}
|
||||
|
||||
void sendBufferingUpdate() {
|
||||
@ -216,38 +156,7 @@ final class VideoPlayer {
|
||||
return exoPlayer.getCurrentPosition();
|
||||
}
|
||||
|
||||
@SuppressWarnings("SuspiciousNameCombination")
|
||||
@VisibleForTesting
|
||||
void sendInitialized() {
|
||||
if (!isInitialized) {
|
||||
return;
|
||||
}
|
||||
VideoSize videoSize = exoPlayer.getVideoSize();
|
||||
int rotationCorrection = 0;
|
||||
int width = videoSize.width;
|
||||
int height = videoSize.height;
|
||||
if (width != 0 && height != 0) {
|
||||
int rotationDegrees = videoSize.unappliedRotationDegrees;
|
||||
// Switch the width/height if video was taken in portrait mode
|
||||
if (rotationDegrees == 90 || rotationDegrees == 270) {
|
||||
width = videoSize.height;
|
||||
height = videoSize.width;
|
||||
}
|
||||
// Rotating the video with ExoPlayer does not seem to be possible with a Surface,
|
||||
// so inform the Flutter code that the widget needs to be rotated to prevent
|
||||
// upside-down playback for videos with rotationDegrees of 180 (other orientations work
|
||||
// correctly without correction).
|
||||
if (rotationDegrees == 180) {
|
||||
rotationCorrection = rotationDegrees;
|
||||
}
|
||||
}
|
||||
videoPlayerEvents.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (isInitialized) {
|
||||
exoPlayer.stop();
|
||||
}
|
||||
textureEntry.release();
|
||||
if (surface != null) {
|
||||
surface.release();
|
||||
|
@ -8,8 +8,10 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class VideoPlayerEventCallbacks implements VideoPlayerCallbacks {
|
||||
@ -66,7 +68,10 @@ final class VideoPlayerEventCallbacks implements VideoPlayerCallbacks {
|
||||
public void onBufferingUpdate(long bufferedPosition) {
|
||||
// iOS supports a list of buffered ranges, so we send as a list with a single range.
|
||||
Map<String, Object> event = new HashMap<>();
|
||||
event.put("values", Collections.singletonList(bufferedPosition));
|
||||
event.put("event", "bufferingUpdate");
|
||||
|
||||
List<? extends Number> range = Arrays.asList(0, bufferedPosition);
|
||||
event.put("values", Collections.singletonList(range));
|
||||
eventSink.success(event);
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@
|
||||
package io.flutter.plugins.videoplayer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.*;
|
||||
@ -20,10 +21,13 @@ import androidx.media3.common.VideoSize;
|
||||
import androidx.media3.datasource.DefaultHttpDataSource;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import io.flutter.view.TextureRegistry;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
@ -130,22 +134,59 @@ public class VideoPlayerTest {
|
||||
verify(httpDataSourceFactorySpy).setDefaultRequestProperties(httpHeaders);
|
||||
}
|
||||
|
||||
private Player.Listener initVideoPlayerAndGetListener() {
|
||||
ArgumentCaptor<Player.Listener> listenerCaptor = ArgumentCaptor.forClass(Player.Listener.class);
|
||||
doNothing().when(fakeExoPlayer).addListener(listenerCaptor.capture());
|
||||
|
||||
// Create a video player that will invoke fakeEventSink as a result of Player.Listener calls.
|
||||
new VideoPlayer(
|
||||
fakeExoPlayer,
|
||||
VideoPlayerEventCallbacks.withSink(fakeEventSink),
|
||||
fakeSurfaceTextureEntry,
|
||||
fakeVideoPlayerOptions,
|
||||
httpDataSourceFactorySpy);
|
||||
|
||||
return Objects.requireNonNull(listenerCaptor.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onPlaybackStateBufferingSendBufferedPositionUpdate() {
|
||||
Player.Listener listener = initVideoPlayerAndGetListener();
|
||||
when(fakeExoPlayer.getBufferedPosition()).thenReturn(10L);
|
||||
|
||||
// Send Player.STATE_BUFFERING to trigger the "bufferingUpdate" event.
|
||||
listener.onPlaybackStateChanged(Player.STATE_BUFFERING);
|
||||
|
||||
verify(fakeEventSink, atLeast(1)).success(eventCaptor.capture());
|
||||
List<HashMap<String, Object>> events = eventCaptor.getAllValues();
|
||||
|
||||
Map<String, Object> expected = new HashMap<>();
|
||||
expected.put("event", "bufferingUpdate");
|
||||
|
||||
List<? extends Number> range = Arrays.asList(0, 10L);
|
||||
expected.put("values", Collections.singletonList(range));
|
||||
|
||||
// We received potentially multiple events, find the one that is a "bufferingUpdate".
|
||||
for (Map<String, Object> event : events) {
|
||||
if (event.get("event") == "bufferingUpdate") {
|
||||
assertEquals(expected, event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fail("No 'bufferingUpdate' event found: " + events);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendInitializedSendsExpectedEvent_90RotationDegrees() {
|
||||
VideoPlayer videoPlayer =
|
||||
new VideoPlayer(
|
||||
fakeExoPlayer,
|
||||
VideoPlayerEventCallbacks.withSink(fakeEventSink),
|
||||
fakeSurfaceTextureEntry,
|
||||
fakeVideoPlayerOptions,
|
||||
httpDataSourceFactorySpy);
|
||||
Player.Listener listener = initVideoPlayerAndGetListener();
|
||||
VideoSize testVideoSize = new VideoSize(100, 200, 90, 1f);
|
||||
|
||||
when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize);
|
||||
when(fakeExoPlayer.getDuration()).thenReturn(10L);
|
||||
|
||||
videoPlayer.isInitialized = true;
|
||||
videoPlayer.sendInitialized();
|
||||
// Send Player.STATE_READY to trigger the "initialized" event.
|
||||
listener.onPlaybackStateChanged(Player.STATE_READY);
|
||||
|
||||
verify(fakeEventSink).success(eventCaptor.capture());
|
||||
HashMap<String, Object> actual = eventCaptor.getValue();
|
||||
@ -161,20 +202,14 @@ public class VideoPlayerTest {
|
||||
|
||||
@Test
|
||||
public void sendInitializedSendsExpectedEvent_270RotationDegrees() {
|
||||
VideoPlayer videoPlayer =
|
||||
new VideoPlayer(
|
||||
fakeExoPlayer,
|
||||
VideoPlayerEventCallbacks.withSink(fakeEventSink),
|
||||
fakeSurfaceTextureEntry,
|
||||
fakeVideoPlayerOptions,
|
||||
httpDataSourceFactorySpy);
|
||||
Player.Listener listener = initVideoPlayerAndGetListener();
|
||||
VideoSize testVideoSize = new VideoSize(100, 200, 270, 1f);
|
||||
|
||||
when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize);
|
||||
when(fakeExoPlayer.getDuration()).thenReturn(10L);
|
||||
|
||||
videoPlayer.isInitialized = true;
|
||||
videoPlayer.sendInitialized();
|
||||
// Send Player.STATE_READY to trigger the "initialized" event.
|
||||
listener.onPlaybackStateChanged(Player.STATE_READY);
|
||||
|
||||
verify(fakeEventSink).success(eventCaptor.capture());
|
||||
HashMap<String, Object> actual = eventCaptor.getValue();
|
||||
@ -190,20 +225,14 @@ public class VideoPlayerTest {
|
||||
|
||||
@Test
|
||||
public void sendInitializedSendsExpectedEvent_0RotationDegrees() {
|
||||
VideoPlayer videoPlayer =
|
||||
new VideoPlayer(
|
||||
fakeExoPlayer,
|
||||
VideoPlayerEventCallbacks.withSink(fakeEventSink),
|
||||
fakeSurfaceTextureEntry,
|
||||
fakeVideoPlayerOptions,
|
||||
httpDataSourceFactorySpy);
|
||||
Player.Listener listener = initVideoPlayerAndGetListener();
|
||||
VideoSize testVideoSize = new VideoSize(100, 200, 0, 1f);
|
||||
|
||||
when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize);
|
||||
when(fakeExoPlayer.getDuration()).thenReturn(10L);
|
||||
|
||||
videoPlayer.isInitialized = true;
|
||||
videoPlayer.sendInitialized();
|
||||
// Send Player.STATE_READY to trigger the "initialized" event.
|
||||
listener.onPlaybackStateChanged(Player.STATE_READY);
|
||||
|
||||
verify(fakeEventSink).success(eventCaptor.capture());
|
||||
HashMap<String, Object> actual = eventCaptor.getValue();
|
||||
@ -219,20 +248,14 @@ public class VideoPlayerTest {
|
||||
|
||||
@Test
|
||||
public void sendInitializedSendsExpectedEvent_180RotationDegrees() {
|
||||
VideoPlayer videoPlayer =
|
||||
new VideoPlayer(
|
||||
fakeExoPlayer,
|
||||
VideoPlayerEventCallbacks.withSink(fakeEventSink),
|
||||
fakeSurfaceTextureEntry,
|
||||
fakeVideoPlayerOptions,
|
||||
httpDataSourceFactorySpy);
|
||||
Player.Listener listener = initVideoPlayerAndGetListener();
|
||||
VideoSize testVideoSize = new VideoSize(100, 200, 180, 1f);
|
||||
|
||||
when(fakeExoPlayer.getVideoSize()).thenReturn(testVideoSize);
|
||||
when(fakeExoPlayer.getDuration()).thenReturn(10L);
|
||||
|
||||
videoPlayer.isInitialized = true;
|
||||
videoPlayer.sendInitialized();
|
||||
// Send Player.STATE_READY to trigger the "initialized" event.
|
||||
listener.onPlaybackStateChanged(Player.STATE_READY);
|
||||
|
||||
verify(fakeEventSink).success(eventCaptor.capture());
|
||||
HashMap<String, Object> actual = eventCaptor.getValue();
|
||||
|
Reference in New Issue
Block a user