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 being enabled). Any changes made to the Player outside of the
observation period are now picked up observation period are now picked up
([#2313](https://github.com/androidx/media/issues/2313)). ([#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: * Downloads:
* Add partial download support for progressive streams. Apps can prepare a * Add partial download support for progressive streams. Apps can prepare a
progressive stream with `DownloadHelper`, and request 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_IDLE
|| player.getPlaybackState() == Player.STATE_ENDED || player.getPlaybackState() == Player.STATE_ENDED
|| (shouldShowPlayIfSuppressed || (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 {} -keepnames class androidx.media3.exoplayer.ExoPlayer {}
-keepclassmembers class androidx.media3.exoplayer.ExoPlayer { -keepclassmembers class androidx.media3.exoplayer.ExoPlayer {
void setImageOutput(androidx.media3.exoplayer.image.ImageOutput); void setImageOutput(androidx.media3.exoplayer.image.ImageOutput);
void setScrubbingModeEnabled(boolean);
boolean isScrubbingModeEnabled();
} }
-keepclasseswithmembers class androidx.media3.exoplayer.image.ImageOutput { -keepclasseswithmembers class androidx.media3.exoplayer.image.ImageOutput {
void onImageAvailable(long, android.graphics.Bitmap); 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.TrackSelectionParameters;
import androidx.media3.common.Tracks; import androidx.media3.common.Tracks;
import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.RepeatModeUtil; import androidx.media3.common.util.RepeatModeUtil;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
@ -92,6 +95,7 @@ import java.util.Formatter;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
/** /**
* A view for controlling {@link Player} instances. * A view for controlling {@link Player} instances.
@ -156,6 +160,14 @@ import java.util.concurrent.CopyOnWriteArrayList;
* <li>Corresponding method: {@link #setAnimationEnabled(boolean)} * <li>Corresponding method: {@link #setAnimationEnabled(boolean)}
* <li>Default: true * <li>Default: true
* </ul> * </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 * <li><b>{@code time_bar_min_update_interval}</b> - Specifies the minimum interval between time
* bar position updates. * bar position updates.
* <ul> * <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. */ /** 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; 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. */ /** The maximum interval between time bar position updates. */
private static final int MAX_UPDATE_INTERVAL_MS = 1_000; 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 PlayerControlViewLayoutManager controlViewLayoutManager;
private final Resources resources; private final Resources resources;
private final ComponentListener componentListener; 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. @SuppressWarnings("deprecation") // Using the deprecated type for now.
private final CopyOnWriteArrayList<VisibilityListener> visibilityListeners; private final CopyOnWriteArrayList<VisibilityListener> visibilityListeners;
@ -442,6 +459,7 @@ public class PlayerControlView extends FrameLayout {
private boolean multiWindowTimeBar; private boolean multiWindowTimeBar;
private boolean scrubbing; private boolean scrubbing;
private int showTimeoutMs; private int showTimeoutMs;
private boolean timeBarScrubbingEnabled;
private int timeBarMinUpdateIntervalMs; private int timeBarMinUpdateIntervalMs;
private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes; private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes;
private long[] adGroupTimesMs; private long[] adGroupTimesMs;
@ -572,6 +590,8 @@ public class PlayerControlView extends FrameLayout {
showSubtitleButton = showSubtitleButton =
a.getBoolean(R.styleable.PlayerControlView_show_subtitle_button, showSubtitleButton); a.getBoolean(R.styleable.PlayerControlView_show_subtitle_button, showSubtitleButton);
showVrButton = a.getBoolean(R.styleable.PlayerControlView_show_vr_button, showVrButton); showVrButton = a.getBoolean(R.styleable.PlayerControlView_show_vr_button, showVrButton);
timeBarScrubbingEnabled =
a.getBoolean(R.styleable.PlayerControlView_time_bar_scrubbing_enabled, false);
setTimeBarMinUpdateInterval( setTimeBarMinUpdateInterval(
a.getInt( a.getInt(
R.styleable.PlayerControlView_time_bar_min_update_interval, R.styleable.PlayerControlView_time_bar_min_update_interval,
@ -598,6 +618,21 @@ public class PlayerControlView extends FrameLayout {
extraPlayedAdGroups = new boolean[0]; extraPlayedAdGroups = new boolean[0];
updateProgressAction = this::updateProgress; 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); durationView = findViewById(R.id.exo_duration);
positionView = findViewById(R.id.exo_position); positionView = findViewById(R.id.exo_position);
@ -1088,6 +1123,17 @@ public class PlayerControlView extends FrameLayout {
return controlViewLayoutManager.isAnimationEnabled(); 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. * 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)); positionView.setText(Util.getStringForTime(formatBuilder, formatter, position));
} }
controlViewLayoutManager.removeHideCallbacks(); 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 @Override
@ -1859,17 +1920,45 @@ public class PlayerControlView extends FrameLayout {
if (positionView != null) { if (positionView != null) {
positionView.setText(Util.getStringForTime(formatBuilder, formatter, position)); 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 @Override
public void onScrubStop(TimeBar timeBar, long position, boolean canceled) { public void onScrubStop(TimeBar timeBar, long position, boolean canceled) {
scrubbing = false; scrubbing = false;
if (!canceled && player != null) { if (player != null) {
if (!canceled) {
seekToTimeBarPosition(player, position); seekToTimeBarPosition(player, position);
} }
if (isExoPlayer(player)) {
try {
checkNotNull(setScrubbingModeEnabledMethod).invoke(player, false);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}
controlViewLayoutManager.resetHideCallbacks(); controlViewLayoutManager.resetHideCallbacks();
} }
@EnsuresNonNullIf(result = true, expression = "#1")
private boolean isExoPlayer(@Nullable Player player) {
return player != null
&& exoplayerClazz != null
&& exoplayerClazz.isAssignableFrom(player.getClass());
}
@Override @Override
public void onDismiss() { public void onDismiss() {
if (needToHideBars) { if (needToHideBars) {

View File

@ -1283,6 +1283,19 @@ public class PlayerView extends FrameLayout implements AdViewProvider {
controller.setShowMultiWindowTimeBar(showMultiWindowTimeBar); 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 * Sets whether a play button is shown if playback is {@linkplain
* Player#getPlaybackSuppressionReason() suppressed}. * Player#getPlaybackSuppressionReason() suppressed}.

View File

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