Integrate PlayerControlView with new scrubbing mode

This also tweaks the logic in `Util.shouldShowPlayButton` to
special-case the 'scrubbing' suppression reason. In most cases of
playback suppression (e.g. transient loss of audio focus due to a phone
call), the recommended UX is to show a 'play' button (i.e. the UI looks
like the player is paused). This doesn't look right when scrubbing
since although 'ongoing' playback is suppressed, the image on screen
is constantly changing, so a 'pause' button is kept (i.e. the UI looks
like the player is playing).

PiperOrigin-RevId: 751385521
This commit is contained in:
ibaker 2025-04-25 06:03:03 -07:00 committed by Copybara-Service
parent 20ab1ea8e5
commit 49b57b8da3
6 changed files with 119 additions and 3 deletions

View File

@ -64,6 +64,13 @@
being enabled). Any changes made to the Player outside of the
observation period are now picked up
([#2313](https://github.com/androidx/media/issues/2313)).
* Add support for ExoPlayer's scrubbing mode to `PlayerControlView`. When
enabled, this puts the player into scrubbing mode when the user starts
dragging the scrubber bar, issues a `player.seekTo` call for every
movement, and then exits scrubbing mode when the touch is lifted from
the screen. This integration can be enabled with either
`time_bar_scrubbing_enabled = true` in XML or the
`setTimeBarScrubbingEnabled(boolean)` method from Java/Kotlin.
* Downloads:
* Add partial download support for progressive streams. Apps can prepare a
progressive stream with `DownloadHelper`, and request a

View File

@ -3713,7 +3713,9 @@ public final class Util {
|| player.getPlaybackState() == Player.STATE_IDLE
|| player.getPlaybackState() == Player.STATE_ENDED
|| (shouldShowPlayIfSuppressed
&& player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE);
&& player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
&& player.getPlaybackSuppressionReason()
!= Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING);
}
/**

View File

@ -12,6 +12,8 @@
-keepnames class androidx.media3.exoplayer.ExoPlayer {}
-keepclassmembers class androidx.media3.exoplayer.ExoPlayer {
void setImageOutput(androidx.media3.exoplayer.image.ImageOutput);
void setScrubbingModeEnabled(boolean);
boolean isScrubbingModeEnabled();
}
-keepclasseswithmembers class androidx.media3.exoplayer.image.ImageOutput {
void onImageAvailable(long, android.graphics.Bitmap);

View File

@ -79,12 +79,15 @@ import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.RepeatModeUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.common.collect.ImmutableList;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@ -92,6 +95,7 @@ import java.util.Formatter;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CopyOnWriteArrayList;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
/**
* A view for controlling {@link Player} instances.
@ -156,6 +160,14 @@ import java.util.concurrent.CopyOnWriteArrayList;
* <li>Corresponding method: {@link #setAnimationEnabled(boolean)}
* <li>Default: true
* </ul>
* <li><b>{@code time_bar_scrubbing_enabled}</b> - Whether the time bar should {@linkplain
* Player#seekTo seek} immediately as the user drags the scrubber around (true), or only seek
* when the user releases the scrubber (false). This can only be used if the {@linkplain
* #setPlayer connected player} is an instance of {@code androidx.media3.exoplayer.ExoPlayer}.
* <ul>
* <li>Corresponding method: {@link #setTimeBarScrubbingEnabled(boolean)}
* <li>Default: {@code false}
* </ul>
* <li><b>{@code time_bar_min_update_interval}</b> - Specifies the minimum interval between time
* bar position updates.
* <ul>
@ -352,6 +364,8 @@ public class PlayerControlView extends FrameLayout {
/** The maximum number of windows that can be shown in a multi-window time bar. */
public static final int MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR = 100;
private static final String TAG = "PlayerControlView";
/** The maximum interval between time bar position updates. */
private static final int MAX_UPDATE_INTERVAL_MS = 1_000;
@ -366,6 +380,9 @@ public class PlayerControlView extends FrameLayout {
private final PlayerControlViewLayoutManager controlViewLayoutManager;
private final Resources resources;
private final ComponentListener componentListener;
@Nullable private final Class<?> exoplayerClazz;
@Nullable private final Method setScrubbingModeEnabledMethod;
@Nullable private final Method isScrubbingModeEnabledMethod;
@SuppressWarnings("deprecation") // Using the deprecated type for now.
private final CopyOnWriteArrayList<VisibilityListener> visibilityListeners;
@ -442,6 +459,7 @@ public class PlayerControlView extends FrameLayout {
private boolean multiWindowTimeBar;
private boolean scrubbing;
private int showTimeoutMs;
private boolean timeBarScrubbingEnabled;
private int timeBarMinUpdateIntervalMs;
private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes;
private long[] adGroupTimesMs;
@ -572,6 +590,8 @@ public class PlayerControlView extends FrameLayout {
showSubtitleButton =
a.getBoolean(R.styleable.PlayerControlView_show_subtitle_button, showSubtitleButton);
showVrButton = a.getBoolean(R.styleable.PlayerControlView_show_vr_button, showVrButton);
timeBarScrubbingEnabled =
a.getBoolean(R.styleable.PlayerControlView_time_bar_scrubbing_enabled, false);
setTimeBarMinUpdateInterval(
a.getInt(
R.styleable.PlayerControlView_time_bar_min_update_interval,
@ -598,6 +618,21 @@ public class PlayerControlView extends FrameLayout {
extraPlayedAdGroups = new boolean[0];
updateProgressAction = this::updateProgress;
Class<?> exoplayerClazz = null;
Method setScrubbingModeEnabledMethod = null;
Method isScrubbingModeEnabledMethod = null;
try {
exoplayerClazz = Class.forName("androidx.media3.exoplayer.ExoPlayer");
setScrubbingModeEnabledMethod =
exoplayerClazz.getMethod("setScrubbingModeEnabled", boolean.class);
isScrubbingModeEnabledMethod = exoplayerClazz.getMethod("isScrubbingModeEnabled");
} catch (ClassNotFoundException | NoSuchMethodException e) {
// Expected if ExoPlayer module not available.
}
this.exoplayerClazz = exoplayerClazz;
this.setScrubbingModeEnabledMethod = setScrubbingModeEnabledMethod;
this.isScrubbingModeEnabledMethod = isScrubbingModeEnabledMethod;
durationView = findViewById(R.id.exo_duration);
positionView = findViewById(R.id.exo_position);
@ -1088,6 +1123,17 @@ public class PlayerControlView extends FrameLayout {
return controlViewLayoutManager.isAnimationEnabled();
}
/**
* Sets whether the time bar should {@linkplain Player#seekTo seek} immediately as the user drags
* the scrubber around (true), or only seek when the user releases the scrubber (false).
*
* <p>This can only be used if the {@linkplain #setPlayer connected player} is an instance of
* {@code androidx.media3.exoplayer.ExoPlayer}.
*/
public void setTimeBarScrubbingEnabled(boolean timeBarScrubbingEnabled) {
this.timeBarScrubbingEnabled = timeBarScrubbingEnabled;
}
/**
* Sets the minimum interval between time bar position updates.
*
@ -1852,6 +1898,21 @@ public class PlayerControlView extends FrameLayout {
positionView.setText(Util.getStringForTime(formatBuilder, formatter, position));
}
controlViewLayoutManager.removeHideCallbacks();
if (player != null && timeBarScrubbingEnabled) {
if (isExoPlayer(player)) {
try {
checkNotNull(setScrubbingModeEnabledMethod).invoke(player, true);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
} else {
Log.w(
TAG,
"Time bar scrubbing is enabled, but player is not an ExoPlayer instance, so ignoring"
+ " (because we can't enable scrubbing mode). player.class="
+ checkNotNull(player).getClass());
}
}
}
@Override
@ -1859,17 +1920,45 @@ public class PlayerControlView extends FrameLayout {
if (positionView != null) {
positionView.setText(Util.getStringForTime(formatBuilder, formatter, position));
}
boolean isScrubbingModeEnabled;
try {
isScrubbingModeEnabled =
isExoPlayer(player)
&& (boolean)
checkNotNull(checkNotNull(isScrubbingModeEnabledMethod).invoke(player));
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
if (isScrubbingModeEnabled) {
seekToTimeBarPosition(checkNotNull(player), position);
}
}
@Override
public void onScrubStop(TimeBar timeBar, long position, boolean canceled) {
scrubbing = false;
if (!canceled && player != null) {
seekToTimeBarPosition(player, position);
if (player != null) {
if (!canceled) {
seekToTimeBarPosition(player, position);
}
if (isExoPlayer(player)) {
try {
checkNotNull(setScrubbingModeEnabledMethod).invoke(player, false);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}
controlViewLayoutManager.resetHideCallbacks();
}
@EnsuresNonNullIf(result = true, expression = "#1")
private boolean isExoPlayer(@Nullable Player player) {
return player != null
&& exoplayerClazz != null
&& exoplayerClazz.isAssignableFrom(player.getClass());
}
@Override
public void onDismiss() {
if (needToHideBars) {

View File

@ -1283,6 +1283,19 @@ public class PlayerView extends FrameLayout implements AdViewProvider {
controller.setShowMultiWindowTimeBar(showMultiWindowTimeBar);
}
/**
* Sets whether the time bar should {@linkplain Player#seekTo seek} immediately as the user drags
* the scrubber around (true), or only seek when the user releases the scrubber (false).
*
* <p>This can only be used if the {@linkplain #setPlayer connected player} is an instance of
* {@code androidx.media3.exoplayer.ExoPlayer}.
*/
@UnstableApi
public void setTimeBarScrubbingEnabled(boolean timeBarScrubbingEnabled) {
Assertions.checkStateNotNull(controller);
controller.setTimeBarScrubbingEnabled(timeBarScrubbingEnabled);
}
/**
* Sets whether a play button is shown if playback is {@linkplain
* Player#getPlaybackSuppressionReason() suppressed}.

View File

@ -90,6 +90,7 @@
<attr name="subtitle_off_icon" format="reference"/>
<attr name="show_vr_button" format="boolean"/>
<attr name="vr_icon" format="reference"/>
<attr name="time_bar_scrubbing_enabled" format="boolean"/>
<attr name="time_bar_min_update_interval" format="integer"/>
<attr name="controller_layout_id" format="reference"/>
<attr name="animation_enabled" format="boolean"/>
@ -146,6 +147,7 @@
<attr name="subtitle_on_icon"/>
<attr name="show_vr_button"/>
<attr name="vr_icon"/>
<attr name="time_bar_scrubbing_enabled"/>
<attr name="time_bar_min_update_interval"/>
<attr name="controller_layout_id"/>
<attr name="animation_enabled"/>
@ -227,6 +229,7 @@
<attr name="subtitle_off_icon"/>
<attr name="show_vr_button"/>
<attr name="vr_icon"/>
<attr name="time_bar_scrubbing_enabled"/>
<attr name="time_bar_min_update_interval"/>
<attr name="controller_layout_id"/>
<attr name="animation_enabled"/>