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: + * + *
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