From 14259a6b11ea2ef6b1f936061d5f9f9d1ec89b57 Mon Sep 17 00:00:00 2001 From: daole Date: Thu, 14 May 2026 16:23:03 +0700 Subject: [PATCH 1/3] [video_player_android] Avoid sending unset duration on initialization --- .../videoplayer/ExoPlayerEventListener.java | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) 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..461c6e4bb91a 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,32 @@ public ExoPlayerEventListener( protected abstract void sendInitialized(); + 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()) { + isWaitingForValidDuration = true; + mainHandler.removeCallbacks(initializationFallback); + 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 +100,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 +112,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) { From 9ebceb4b6721c1e3ec1cc0a30478ce62c59a15da Mon Sep 17 00:00:00 2001 From: daole Date: Thu, 14 May 2026 17:03:49 +0700 Subject: [PATCH 2/3] Cancel pending initialization fallback on video player dispose --- .../videoplayer/ExoPlayerEventListener.java | 13 +++- .../plugins/videoplayer/VideoPlayer.java | 8 ++- .../ExoPlayerEventListenerTest.java | 64 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) 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 461c6e4bb91a..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 @@ -65,6 +65,12 @@ 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; } @@ -79,9 +85,10 @@ private void maybeSendInitialized() { } if (!hasValidDuration() && shouldWaitForValidDuration()) { - isWaitingForValidDuration = true; - mainHandler.removeCallbacks(initializationFallback); - mainHandler.postDelayed(initializationFallback, DURATION_UNSET_INITIALIZATION_TIMEOUT_MS); + if (!isWaitingForValidDuration) { + isWaitingForValidDuration = true; + mainHandler.postDelayed(initializationFallback, DURATION_UNSET_INITIALIZATION_TIMEOUT_MS); + } return; } 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( From d8c0f25d93f395622a3cbfea4898c5e0186dbdf7 Mon Sep 17 00:00:00 2001 From: daole Date: Fri, 15 May 2026 14:28:34 +0700 Subject: [PATCH 3/3] Increase duration unset initialization timeout from 500ms to 2000ms --- .../io/flutter/plugins/videoplayer/ExoPlayerEventListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 354f64b296b9..9bf7df8ab758 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 @@ -16,7 +16,7 @@ import androidx.media3.exoplayer.ExoPlayer; public abstract class ExoPlayerEventListener implements Player.Listener { - private static final long DURATION_UNSET_INITIALIZATION_TIMEOUT_MS = 500; + private static final long DURATION_UNSET_INITIALIZATION_TIMEOUT_MS = 2000; private boolean isInitialized = false; private boolean isWaitingForValidDuration = false; private final Handler mainHandler = new Handler(Looper.getMainLooper());