diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java index 33988786a78a..354f64b296b9 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java @@ -4,16 +4,30 @@ package io.flutter.plugins.videoplayer; +import android.os.Handler; +import android.os.Looper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; +import androidx.media3.common.Timeline; import androidx.media3.common.Tracks; import androidx.media3.exoplayer.ExoPlayer; public abstract class ExoPlayerEventListener implements Player.Listener { + private static final long DURATION_UNSET_INITIALIZATION_TIMEOUT_MS = 500; private boolean isInitialized = false; + private boolean isWaitingForValidDuration = false; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final Runnable initializationFallback = + () -> { + if (!isInitialized && isWaitingForValidDuration) { + isWaitingForValidDuration = false; + isInitialized = true; + sendInitialized(); + } + }; protected final ExoPlayer exoPlayer; protected final VideoPlayerCallbacks events; @@ -51,6 +65,39 @@ public ExoPlayerEventListener( protected abstract void sendInitialized(); + /** Cancels pending initialization callbacks when the player is disposed. */ + public void dispose() { + isWaitingForValidDuration = false; + mainHandler.removeCallbacks(initializationFallback); + } + + private boolean hasValidDuration() { + return exoPlayer.getDuration() != C.TIME_UNSET; + } + + private boolean shouldWaitForValidDuration() { + return !exoPlayer.isCurrentMediaItemLive() && !exoPlayer.isCurrentMediaItemDynamic(); + } + + private void maybeSendInitialized() { + if (isInitialized) { + return; + } + + if (!hasValidDuration() && shouldWaitForValidDuration()) { + if (!isWaitingForValidDuration) { + isWaitingForValidDuration = true; + mainHandler.postDelayed(initializationFallback, DURATION_UNSET_INITIALIZATION_TIMEOUT_MS); + } + return; + } + + isWaitingForValidDuration = false; + isInitialized = true; + mainHandler.removeCallbacks(initializationFallback); + sendInitialized(); + } + @Override public void onPlaybackStateChanged(final int playbackState) { PlatformPlaybackState platformState = PlatformPlaybackState.UNKNOWN; @@ -60,10 +107,7 @@ public void onPlaybackStateChanged(final int playbackState) { break; case Player.STATE_READY: platformState = PlatformPlaybackState.READY; - if (!isInitialized) { - isInitialized = true; - sendInitialized(); - } + maybeSendInitialized(); break; case Player.STATE_ENDED: platformState = PlatformPlaybackState.ENDED; @@ -75,6 +119,13 @@ public void onPlaybackStateChanged(final int playbackState) { events.onPlaybackStateChanged(platformState); } + @Override + public void onTimelineChanged(@NonNull Timeline timeline, int reason) { + if (isWaitingForValidDuration && exoPlayer.getPlaybackState() == Player.STATE_READY) { + maybeSendInitialized(); + } + } + @Override public void onPlayerError(@NonNull final PlaybackException error) { if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java index 7cfb5c1c13be..3d772e59eac0 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java @@ -33,6 +33,7 @@ public abstract class VideoPlayer implements VideoPlayerInstanceApi { @NonNull protected final VideoPlayerCallbacks videoPlayerEvents; @Nullable protected final SurfaceProducer surfaceProducer; @Nullable private DisposeHandler disposeHandler; + @Nullable private ExoPlayerEventListener exoPlayerEventListener; @NonNull protected ExoPlayer exoPlayer; // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. @UnstableApi @Nullable protected DefaultTrackSelector trackSelector; @@ -75,7 +76,8 @@ public VideoPlayer( exoPlayer.setMediaItem(mediaItem); exoPlayer.prepare(); - exoPlayer.addListener(createExoPlayerEventListener(exoPlayer, surfaceProducer)); + exoPlayerEventListener = createExoPlayerEventListener(exoPlayer, surfaceProducer); + exoPlayer.addListener(exoPlayerEventListener); setAudioAttributes(exoPlayer, options.mixWithOthers); } @@ -237,6 +239,10 @@ public void dispose() { if (disposeHandler != null) { disposeHandler.onDispose(); } + if (exoPlayerEventListener != null) { + exoPlayerEventListener.dispose(); + exoPlayerEventListener = null; + } exoPlayer.release(); } } diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java index 529e8d5a6af9..2456b74d27ab 100644 --- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java @@ -4,14 +4,21 @@ package io.flutter.plugins.videoplayer; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import android.os.Looper; +import androidx.media3.common.C; import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; +import androidx.media3.common.Timeline; import androidx.media3.exoplayer.ExoPlayer; +import java.time.Duration; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -20,6 +27,7 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; /** * Unit tests for {@link ExoPlayerEventListener}. @@ -87,6 +95,8 @@ public void onPlaybackStateChangedIdleSendsIdle() { @Test public void onPlaybackStateChangedReadySendsInitializedAndReady() { + when(mockExoPlayer.getDuration()).thenReturn(10L); + eventListener.onPlaybackStateChanged(Player.STATE_READY); verify(mockCallbacks).onPlaybackStateChanged(PlatformPlaybackState.READY); @@ -94,6 +104,60 @@ public void onPlaybackStateChangedReadySendsInitializedAndReady() { assertTrue(eventListener.calledSendInitialized()); } + @Test + public void onPlaybackStateChangedReadyDelaysInitializationWhenDurationUnsetForNonLiveMedia() { + when(mockExoPlayer.getDuration()).thenReturn(C.TIME_UNSET); + when(mockExoPlayer.isCurrentMediaItemLive()).thenReturn(false); + when(mockExoPlayer.isCurrentMediaItemDynamic()).thenReturn(false); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + + verify(mockCallbacks).onPlaybackStateChanged(PlatformPlaybackState.READY); + verifyNoMoreInteractions(mockCallbacks); + assertFalse(eventListener.calledSendInitialized()); + } + + @Test + public void onPlaybackStateChangedReadySendsInitializedImmediatelyWhenDurationAvailable() { + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + + assertTrue(eventListener.calledSendInitialized()); + } + + @Test + public void onPlaybackStateChangedReadyFallsBackWhenDurationRemainsUnset() { + when(mockExoPlayer.getDuration()).thenReturn(C.TIME_UNSET); + when(mockExoPlayer.isCurrentMediaItemLive()).thenReturn(false); + when(mockExoPlayer.isCurrentMediaItemDynamic()).thenReturn(false); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + assertFalse(eventListener.calledSendInitialized()); + + Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500)); + + assertTrue(eventListener.calledSendInitialized()); + } + + @Test + public void onTimelineChangedSendsInitializedWhenDurationBecomesAvailable() { + when(mockExoPlayer.getDuration()).thenReturn(C.TIME_UNSET); + when(mockExoPlayer.isCurrentMediaItemLive()).thenReturn(false); + when(mockExoPlayer.isCurrentMediaItemDynamic()).thenReturn(false); + + eventListener.onPlaybackStateChanged(Player.STATE_READY); + assertFalse(eventListener.calledSendInitialized()); + + when(mockExoPlayer.getPlaybackState()).thenReturn(Player.STATE_READY); + when(mockExoPlayer.getDuration()).thenReturn(10L); + + eventListener.onTimelineChanged( + mock(Timeline.class), Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + + assertTrue(eventListener.calledSendInitialized()); + } + @Test public void onErrorBehindLiveWindowSeekToDefaultAndPrepare() { eventListener.onPlayerError(