diff --git a/RELEASENOTES.md b/RELEASENOTES.md index afd1d63f2b..a9f8024557 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,6 +12,9 @@ * Fix issue where media item transition fails due to recoverable renderer error during initialization of the next media item ([#2229](https://github.com/androidx/media/issues/2229)). + * Add `ExoPlayer.setScrubbingModeEnabled(boolean)` method. This optimizes + the player for many frequent seeks (for example, from a user dragging a + scrubber bar around). * Transformer: * Filling an initial gap (added via `addGap()`) with silent audio now requires explicitly setting `setForceAudioTrack(true)` in diff --git a/api.txt b/api.txt index 79c92db310..ed26fe3804 100644 --- a/api.txt +++ b/api.txt @@ -885,6 +885,7 @@ package androidx.media3.common { field public static final int MEDIA_ITEM_TRANSITION_REASON_REPEAT = 0; // 0x0 field public static final int MEDIA_ITEM_TRANSITION_REASON_SEEK = 2; // 0x2 field public static final int PLAYBACK_SUPPRESSION_REASON_NONE = 0; // 0x0 + field public static final int PLAYBACK_SUPPRESSION_REASON_SCRUBBING = 4; // 0x4 field public static final int PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS = 1; // 0x1 field public static final int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT = 3; // 0x3 field @Deprecated public static final int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE = 2; // 0x2 @@ -969,7 +970,7 @@ package androidx.media3.common { @IntDef({androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM, androidx.media3.common.Player.PLAY_WHEN_READY_CHANGE_REASON_SUPPRESSED_TOO_LONG}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.PlayWhenReadyChangeReason { } - @IntDef({androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_NONE, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.PlaybackSuppressionReason { + @IntDef({androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_NONE, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT, androidx.media3.common.Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.PlaybackSuppressionReason { } public static final class Player.PositionInfo { diff --git a/libraries/common/src/main/java/androidx/media3/common/Player.java b/libraries/common/src/main/java/androidx/media3/common/Player.java index 725e77a064..c7b11e25a0 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Player.java +++ b/libraries/common/src/main/java/androidx/media3/common/Player.java @@ -1284,11 +1284,17 @@ public interface Player { int PLAY_WHEN_READY_CHANGE_REASON_SUPPRESSED_TOO_LONG = 6; /** - * Reason why playback is suppressed even though {@link #getPlayWhenReady()} is {@code true}. One - * of {@link #PLAYBACK_SUPPRESSION_REASON_NONE}, {@link - * #PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS}, {@link - * #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE} or {@link - * #PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT}. + * Reason why playback is suppressed even though {@link #getPlayWhenReady()} is {@code true}. + * + *

One of: + * + *

*/ // @Target list includes both 'default' targets and TYPE_USE, to ensure backwards compatibility // with Kotlin usages from before TYPE_USE was added. @@ -1300,7 +1306,8 @@ public interface Player { PLAYBACK_SUPPRESSION_REASON_NONE, PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS, PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_ROUTE, - PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT + PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT, + PLAYBACK_SUPPRESSION_REASON_SCRUBBING }) @interface PlaybackSuppressionReason {} @@ -1321,6 +1328,9 @@ public interface Player { */ int PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT = 3; + /** Playback is suppressed because the player is currently scrubbing. */ + int PLAYBACK_SUPPRESSION_REASON_SCRUBBING = 4; + /** * Repeat modes for playback. One of {@link #REPEAT_MODE_OFF}, {@link #REPEAT_MODE_ONE} or {@link * #REPEAT_MODE_ALL}. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java index 204b189719..be9a107e79 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java @@ -1449,6 +1449,20 @@ public interface ExoPlayer extends Player { @UnstableApi boolean getSkipSilenceEnabled(); + /** + * Sets whether to optimize the player for scrubbing (many frequent seeks). + * + *

The player may consume more resources in this mode, so it should only be used for short + * periods of time in response to user interaction (e.g. dragging on a progress bar UI element). + * + *

During scrubbing mode playback is {@linkplain Player#getPlaybackSuppressionReason() + * suppressed} with {@link Player#PLAYBACK_SUPPRESSION_REASON_SCRUBBING}. + * + * @param scrubbingModeEnabled Whether scrubbing mode should be enabled. + */ + @UnstableApi + void setScrubbingModeEnabled(boolean scrubbingModeEnabled); + /** * Sets a {@link List} of {@linkplain Effect video effects} that will be applied to each video * frame. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index eb26340a73..818d068861 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -35,7 +35,6 @@ import static androidx.media3.exoplayer.Renderer.MSG_SET_PRIORITY; import static androidx.media3.exoplayer.Renderer.MSG_SET_SCALING_MODE; import static androidx.media3.exoplayer.Renderer.MSG_SET_SKIP_SILENCE_ENABLED; import static androidx.media3.exoplayer.Renderer.MSG_SET_VIDEO_EFFECTS; -import static androidx.media3.exoplayer.Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER; import static androidx.media3.exoplayer.Renderer.MSG_SET_VIDEO_OUTPUT_RESOLUTION; import static java.lang.Math.max; import static java.lang.Math.min; @@ -182,6 +181,7 @@ import java.util.concurrent.CopyOnWriteArraySet; private @DiscontinuityReason int pendingDiscontinuityReason; private boolean pendingDiscontinuity; private boolean foregroundMode; + private boolean scrubbingModeEnabled; private SeekParameters seekParameters; private ShuffleOrder shuffleOrder; private PreloadConfiguration preloadConfiguration; @@ -370,7 +370,8 @@ import java.util.concurrent.CopyOnWriteArraySet; playbackInfoUpdateListener, playerId, builder.playbackLooperProvider, - preloadConfiguration); + preloadConfiguration, + frameMetadataListener); Looper playbackLooper = internalPlayer.getPlaybackLooper(); volume = 1; @@ -447,8 +448,6 @@ import java.util.concurrent.CopyOnWriteArraySet; sendRendererMessage( TRACK_TYPE_VIDEO, MSG_SET_CHANGE_FRAME_RATE_STRATEGY, videoChangeFrameRateStrategy); sendRendererMessage(TRACK_TYPE_AUDIO, MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); - sendRendererMessage( - TRACK_TYPE_VIDEO, MSG_SET_VIDEO_FRAME_METADATA_LISTENER, frameMetadataListener); sendRendererMessage( TRACK_TYPE_CAMERA_MOTION, MSG_SET_CAMERA_MOTION_LISTENER, frameMetadataListener); sendRendererMessage(MSG_SET_PRIORITY, priority); @@ -1553,6 +1552,17 @@ import java.util.concurrent.CopyOnWriteArraySet; listener -> listener.onSkipSilenceEnabledChanged(newSkipSilenceEnabled)); } + @Override + public void setScrubbingModeEnabled(boolean scrubbingModeEnabled) { + verifyApplicationThread(); + if (scrubbingModeEnabled == this.scrubbingModeEnabled) { + return; + } + this.scrubbingModeEnabled = scrubbingModeEnabled; + internalPlayer.setScrubbingModeEnabled(scrubbingModeEnabled); + updatePlayWhenReady(playbackInfo.playWhenReady, playbackInfo.playWhenReadyChangeReason); + } + @Override public AnalyticsCollector getAnalyticsCollector() { verifyApplicationThread(); @@ -2761,6 +2771,9 @@ import java.util.concurrent.CopyOnWriteArraySet; } private @PlaybackSuppressionReason int computePlaybackSuppressionReason(boolean playWhenReady) { + if (scrubbingModeEnabled) { + return Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING; + } if (suitableOutputChecker != null && !suitableOutputChecker.isSelectedOutputSuitableForPlayback()) { return Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT; diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java index 23f74be6c2..88dfe8bfc8 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -28,6 +28,7 @@ import static java.lang.Math.max; import static java.lang.Math.min; import android.content.Context; +import android.media.MediaFormat; import android.os.Handler; import android.os.Looper; import android.os.Message; @@ -72,6 +73,7 @@ import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.trackselection.TrackSelector; import androidx.media3.exoplayer.trackselection.TrackSelectorResult; import androidx.media3.exoplayer.upstream.BandwidthMeter; +import androidx.media3.exoplayer.video.VideoFrameMetadataListener; import com.google.common.base.Supplier; import com.google.common.collect.ImmutableList; import java.io.IOException; @@ -89,7 +91,8 @@ import java.util.concurrent.atomic.AtomicBoolean; MediaSourceList.MediaSourceListInfoRefreshListener, PlaybackParametersListener, PlayerMessage.Sender, - AudioFocusManager.PlayerControl { + AudioFocusManager.PlayerControl, + VideoFrameMetadataListener { private static final String TAG = "ExoPlayerImplInternal"; @@ -168,6 +171,9 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int MSG_SET_VOLUME = 32; private static final int MSG_AUDIO_FOCUS_PLAYER_COMMAND = 33; private static final int MSG_AUDIO_FOCUS_VOLUME_MULTIPLIER = 34; + private static final int MSG_SET_VIDEO_FRAME_METADATA_LISTENER = 35; + private static final int MSG_SET_SCRUBBING_MODE_ENABLED = 36; + private static final int MSG_SEEK_COMPLETED_IN_SCRUBBING_MODE = 37; private static final long BUFFERING_MAXIMUM_INTERVAL_MS = Util.usToMs(Renderer.DEFAULT_DURATION_TO_PROGRESS_US); @@ -216,6 +222,9 @@ import java.util.concurrent.atomic.AtomicBoolean; private final boolean hasSecondaryRenderers; private final AudioFocusManager audioFocusManager; private SeekParameters seekParameters; + private boolean scrubbingModeEnabled; + private boolean seekIsPendingWhileScrubbing; + @Nullable private SeekPosition queuedSeekWhileScrubbing; private PlaybackInfo playbackInfo; private PlaybackInfoUpdate playbackInfoUpdate; private boolean released; @@ -265,7 +274,8 @@ import java.util.concurrent.atomic.AtomicBoolean; PlaybackInfoUpdateListener playbackInfoUpdateListener, PlayerId playerId, @Nullable PlaybackLooperProvider playbackLooperProvider, - PreloadConfiguration preloadConfiguration) { + PreloadConfiguration preloadConfiguration, + VideoFrameMetadataListener videoFrameMetadataListener) { this.playbackInfoUpdateListener = playbackInfoUpdateListener; this.trackSelector = trackSelector; this.emptyTrackSelectorResult = emptyTrackSelectorResult; @@ -340,6 +350,15 @@ import java.util.concurrent.atomic.AtomicBoolean; handler = clock.createHandler(this.playbackLooper, this); audioFocusManager = new AudioFocusManager(context, playbackLooper, /* playerControl= */ this); + VideoFrameMetadataListener internalVideoFrameMetadataListener = + (presentationTimeUs, releaseTimeNs, format, mediaFormat) -> { + videoFrameMetadataListener.onVideoFrameAboutToBeRendered( + presentationTimeUs, releaseTimeNs, format, mediaFormat); + onVideoFrameAboutToBeRendered(presentationTimeUs, releaseTimeNs, format, mediaFormat); + }; + handler + .obtainMessage(MSG_SET_VIDEO_FRAME_METADATA_LISTENER, internalVideoFrameMetadataListener) + .sendToTarget(); } private MediaPeriodHolder createMediaPeriodHolder( @@ -405,6 +424,10 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.obtainMessage(MSG_SET_SEEK_PARAMETERS, seekParameters).sendToTarget(); } + public void setScrubbingModeEnabled(boolean scrubbingModeEnabled) { + handler.obtainMessage(MSG_SET_SCRUBBING_MODE_ENABLED, scrubbingModeEnabled).sendToTarget(); + } + public void stop() { handler.obtainMessage(MSG_STOP).sendToTarget(); } @@ -483,6 +506,13 @@ import java.util.concurrent.atomic.AtomicBoolean; setVolumeInternal(volume); } + private void setVideoFrameMetadataListenerInternal( + VideoFrameMetadataListener videoFrameMetadataListener) throws ExoPlaybackException { + for (RendererHolder renderer : renderers) { + renderer.setVideoFrameMetadataListener(videoFrameMetadataListener); + } + } + @Override public synchronized void sendMessage(PlayerMessage message) { if (released || !playbackLooper.getThread().isAlive()) { @@ -613,6 +643,19 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.obtainMessage(MSG_AUDIO_FOCUS_PLAYER_COMMAND, playerCommand, 0).sendToTarget(); } + // VideoFrameMetadataListener implementation + + @Override + public void onVideoFrameAboutToBeRendered( + long presentationTimeUs, + long releaseTimeNs, + Format format, + @Nullable MediaFormat mediaFormat) { + if (seekIsPendingWhileScrubbing) { + handler.obtainMessage(MSG_SEEK_COMPLETED_IN_SCRUBBING_MODE).sendToTarget(); + } + } + // Handler.Callback implementation. @SuppressWarnings({"unchecked", "WrongConstant"}) // Casting message payload types and IntDef. @@ -643,7 +686,14 @@ import java.util.concurrent.atomic.AtomicBoolean; doSomeWork(); break; case MSG_SEEK_TO: - seekToInternal((SeekPosition) msg.obj); + seekToInternal((SeekPosition) msg.obj, /* incrementAcks= */ true); + break; + case MSG_SEEK_COMPLETED_IN_SCRUBBING_MODE: + seekIsPendingWhileScrubbing = false; + if (queuedSeekWhileScrubbing != null) { + seekToInternal(queuedSeekWhileScrubbing, /* incrementAcks= */ false); + queuedSeekWhileScrubbing = null; + } break; case MSG_SET_PLAYBACK_PARAMETERS: setPlaybackParametersInternal((PlaybackParameters) msg.obj); @@ -651,6 +701,9 @@ import java.util.concurrent.atomic.AtomicBoolean; case MSG_SET_SEEK_PARAMETERS: setSeekParametersInternal((SeekParameters) msg.obj); break; + case MSG_SET_SCRUBBING_MODE_ENABLED: + setScrubbingModeEnabledInternal((Boolean) msg.obj); + break; case MSG_SET_FOREGROUND_MODE: setForegroundModeInternal( /* foregroundMode= */ msg.arg1 != 0, /* processedFlag= */ (AtomicBoolean) msg.obj); @@ -725,6 +778,9 @@ import java.util.concurrent.atomic.AtomicBoolean; case MSG_AUDIO_FOCUS_VOLUME_MULTIPLIER: handleAudioFocusVolumeMultiplierChange(); break; + case MSG_SET_VIDEO_FRAME_METADATA_LISTENER: + setVideoFrameMetadataListenerInternal((VideoFrameMetadataListener) msg.obj); + break; case MSG_RELEASE: releaseInternal(); // Return immediately to not send playback info updates after release. @@ -1486,8 +1542,13 @@ import java.util.concurrent.atomic.AtomicBoolean; MSG_DO_SOME_WORK, thisOperationStartTimeMs + wakeUpTimeIntervalMs); } - private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { - playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + private void seekToInternal(SeekPosition seekPosition, boolean incrementAcks) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(incrementAcks ? 1 : 0); + if (seekIsPendingWhileScrubbing) { + queuedSeekWhileScrubbing = seekPosition; + return; + } MediaPeriodId periodId; long periodPositionUs; @@ -1568,6 +1629,7 @@ import java.util.concurrent.atomic.AtomicBoolean; return; } } + seekIsPendingWhileScrubbing = scrubbingModeEnabled; newPeriodPositionUs = seekToPeriodPosition( periodId, @@ -1698,6 +1760,20 @@ import java.util.concurrent.atomic.AtomicBoolean; this.seekParameters = seekParameters; } + private void setScrubbingModeEnabledInternal(boolean scrubbingModeEnabled) + throws ExoPlaybackException { + this.scrubbingModeEnabled = scrubbingModeEnabled; + if (!scrubbingModeEnabled) { + seekIsPendingWhileScrubbing = false; + handler.removeMessages(MSG_SEEK_COMPLETED_IN_SCRUBBING_MODE); + if (queuedSeekWhileScrubbing != null) { + // Immediately seek to the latest received scrub position (interrupting a pending seek). + seekToInternal(queuedSeekWhileScrubbing, /* incrementAcks= */ false); + queuedSeekWhileScrubbing = null; + } + } + } + private void setForegroundModeInternal( boolean foregroundMode, @Nullable AtomicBoolean processedFlag) { if (this.foregroundMode != foregroundMode) { @@ -1773,6 +1849,8 @@ import java.util.concurrent.atomic.AtomicBoolean; boolean releaseMediaSourceList, boolean resetError) { handler.removeMessages(MSG_DO_SOME_WORK); + seekIsPendingWhileScrubbing = false; + queuedSeekWhileScrubbing = null; pendingRecoverableRendererError = null; updateRebufferingState(/* isRebuffering= */ false, /* resetLastRebufferRealtimeMs= */ true); mediaClock.stop(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java index 756c881b2a..f3e828c9e3 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererHolder.java @@ -38,6 +38,7 @@ import androidx.media3.exoplayer.source.SampleStream; import androidx.media3.exoplayer.text.TextRenderer; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.trackselection.TrackSelectorResult; +import androidx.media3.exoplayer.video.VideoFrameMetadataListener; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -784,6 +785,19 @@ import java.util.Objects; } } + public void setVideoFrameMetadataListener(VideoFrameMetadataListener videoFrameMetadataListener) + throws ExoPlaybackException { + if (getTrackType() != TRACK_TYPE_VIDEO) { + return; + } + primaryRenderer.handleMessage( + Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER, videoFrameMetadataListener); + if (secondaryRenderer != null) { + secondaryRenderer.handleMessage( + Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER, videoFrameMetadataListener); + } + } + /** Sets the volume on the renderer. */ public void setVolume(float volume) throws ExoPlaybackException { if (getTrackType() != TRACK_TYPE_AUDIO) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java index bc1011837c..60e4129f3a 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java @@ -622,6 +622,12 @@ public class SimpleExoPlayer extends BasePlayer implements ExoPlayer { player.setSkipSilenceEnabled(skipSilenceEnabled); } + @Override + public void setScrubbingModeEnabled(boolean scrubbingModeEnabled) { + blockUntilConstructorFinished(); + player.setScrubbingModeEnabled(scrubbingModeEnabled); + } + @Override public AnalyticsCollector getAnalyticsCollector() { blockUntilConstructorFinished(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java index 0bdef3bf33..b341f9b2d3 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/util/EventLogger.java @@ -741,6 +741,8 @@ public class EventLogger implements AnalyticsListener { return "TRANSIENT_AUDIO_FOCUS_LOSS"; case Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT: return "UNSUITABLE_AUDIO_OUTPUT"; + case Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING: + return "SCRUBBING"; default: return "?"; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerScrubbingTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerScrubbingTest.java new file mode 100644 index 0000000000..be97959057 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerScrubbingTest.java @@ -0,0 +1,151 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer; + +import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.advance; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.graphics.SurfaceTexture; +import android.view.Surface; +import androidx.media3.common.C; +import androidx.media3.common.Player; +import androidx.media3.common.Player.PositionInfo; +import androidx.media3.common.Timeline; +import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.video.VideoFrameMetadataListener; +import androidx.media3.test.utils.ExoPlayerTestRunner; +import androidx.media3.test.utils.FakeMediaPeriod.TrackDataFactory; +import androidx.media3.test.utils.FakeMediaSource; +import androidx.media3.test.utils.FakeRenderer; +import androidx.media3.test.utils.FakeTimeline; +import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; +import androidx.media3.test.utils.TestExoPlayerBuilder; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +/** Tests for {@linkplain ExoPlayer#setScrubbingModeEnabled(boolean) scrubbing mode}. */ +@RunWith(AndroidJUnit4.class) +public final class ExoPlayerScrubbingTest { + + @Test + public void scrubbingMode_suppressesPlayback() throws Exception { + Timeline timeline = new FakeTimeline(); + FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()) + .setRenderers(renderer) + .build(); + Player.Listener mockListener = mock(Player.Listener.class); + player.addListener(mockListener); + + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + player.play(); + + advance(player).untilPosition(0, 2000); + + player.setScrubbingModeEnabled(true); + verify(mockListener) + .onPlaybackSuppressionReasonChanged(Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING); + + player.setScrubbingModeEnabled(false); + verify(mockListener) + .onPlaybackSuppressionReasonChanged(Player.PLAYBACK_SUPPRESSION_REASON_NONE); + + player.release(); + } + + @Test + public void scrubbingMode_pendingSeekIsNotPreempted() throws Exception { + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition.Builder().setWindowPositionInFirstPeriodUs(0).build()); + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1)); + player.setVideoSurface(surface); + Player.Listener mockListener = mock(Player.Listener.class); + player.addListener(mockListener); + + player.setMediaSource( + new FakeMediaSource( + timeline, + DrmSessionManager.DRM_UNSUPPORTED, + TrackDataFactory.samplesWithRateDurationAndKeyframeInterval( + /* initialSampleTimeUs= */ 0, + /* sampleRate= */ 30, + /* durationUs= */ DEFAULT_WINDOW_DURATION_US, + /* keyFrameInterval= */ 60), + ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + player.play(); + + advance(player).untilPosition(0, 1000); + + VideoFrameMetadataListener mockVideoFrameMetadataListener = + mock(VideoFrameMetadataListener.class); + player.setVideoFrameMetadataListener(mockVideoFrameMetadataListener); + player.setScrubbingModeEnabled(true); + advance(player).untilPendingCommandsAreFullyHandled(); + player.seekTo(2500); + player.seekTo(3000); + player.seekTo(3500); + // Allow the 2500 and 3500 seeks to complete (the 3000 seek should be dropped). + advance(player).untilPendingCommandsAreFullyHandled(); + + player.seekTo(4000); + player.seekTo(4500); + // Disabling scrubbing mode should immediately execute the last received seek (pre-empting a + // previous one), so we expect the 4500 seek to be resolved and the 4000 seek to be dropped. + player.setScrubbingModeEnabled(false); + advance(player).untilPendingCommandsAreFullyHandled(); + player.clearVideoFrameMetadataListener(mockVideoFrameMetadataListener); + + advance(player).untilState(Player.STATE_ENDED); + player.release(); + surface.release(); + + ArgumentCaptor presentationTimeUsCaptor = ArgumentCaptor.forClass(Long.class); + verify(mockVideoFrameMetadataListener, atLeastOnce()) + .onVideoFrameAboutToBeRendered(presentationTimeUsCaptor.capture(), anyLong(), any(), any()); + + assertThat(presentationTimeUsCaptor.getAllValues()) + .containsExactly(2_500_000L, 3_500_000L, 4_500_000L) + .inOrder(); + + // Confirm that even though we dropped some intermediate seeks, every seek request still + // resulted in a position discontinuity callback. + ArgumentCaptor newPositionCaptor = ArgumentCaptor.forClass(PositionInfo.class); + verify(mockListener, atLeastOnce()) + .onPositionDiscontinuity( + /* oldPosition= */ any(), + newPositionCaptor.capture(), + eq(Player.DISCONTINUITY_REASON_SEEK)); + assertThat(newPositionCaptor.getAllValues().stream().map(p -> p.positionMs)) + .containsExactly(2500L, 3000L, 3500L, 4000L, 4500L) + .inOrder(); + } +} diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubExoPlayer.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubExoPlayer.java index c2e646145f..0ef0431c1d 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubExoPlayer.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubExoPlayer.java @@ -215,6 +215,11 @@ public class StubExoPlayer extends StubPlayer implements ExoPlayer { throw new UnsupportedOperationException(); } + @Override + public void setScrubbingModeEnabled(boolean scrubbingModeEnabled) { + throw new UnsupportedOperationException(); + } + @Override public void setVideoEffects(List videoEffects) { throw new UnsupportedOperationException();