diff options
11 files changed, 428 insertions, 74 deletions
diff --git a/packages/SystemUI/res/layout/media_session_view.xml b/packages/SystemUI/res/layout/media_session_view.xml index 23d7211867ca..b67b7bc3b92e 100644 --- a/packages/SystemUI/res/layout/media_session_view.xml +++ b/packages/SystemUI/res/layout/media_session_view.xml @@ -157,7 +157,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:barrierDirection="start" - app:constraint_referenced_ids="actionPrev,media_progress_bar,actionNext,action0,action1,action2,action3,action4" + app:constraint_referenced_ids="actionPrev,media_scrubbing_elapsed_time,media_progress_bar,actionNext,media_scrubbing_total_time,action0,action1,action2,action3,action4" /> <androidx.constraintlayout.widget.Barrier android:id="@+id/media_action_barrier_end" @@ -167,7 +167,7 @@ app:layout_constraintTop_toBottomOf="@id/media_seamless" app:layout_constraintBottom_toBottomOf="parent" app:barrierDirection="end" - app:constraint_referenced_ids="actionPrev,media_progress_bar,actionNext,action0,action1,action2,action3,action4" + app:constraint_referenced_ids="actionPrev,media_scrubbing_elapsed_time,media_progress_bar,actionNext,media_scrubbing_total_time,action0,action1,action2,action3,action4" app:layout_constraintStart_toStartOf="parent" /> @@ -177,7 +177,7 @@ android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:barrierDirection="top" - app:constraint_referenced_ids="actionPrev,media_progress_bar,actionNext,action0,action1,action2,action3,action4" + app:constraint_referenced_ids="actionPrev,media_scrubbing_elapsed_time,media_progress_bar,actionNext,media_scrubbing_total_time,action0,action1,action2,action3,action4" /> <!-- Button visibility will be controlled in code --> @@ -192,6 +192,22 @@ android:layout_marginTop="0dp" /> + <!-- Elapsed time, shown only when scrubbing --> + <!-- The space to the left of the progress bar will either be actionPrev or + media_scrubbing_elapsed_time, so they use the same layout constraints. Visibilities of + elements are controlled in code. --> + <TextView + android:id="@+id/media_scrubbing_elapsed_time" + style="@style/MediaPlayer.ScrubbingTime" + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_marginStart="4dp" + android:layout_marginEnd="0dp" + android:layout_marginBottom="@dimen/qs_media_padding" + android:layout_marginTop="0dp" + android:visibility="gone" + /> + <!-- Seek Bar --> <!-- As per Material Design on Bidirectionality, this is forced to LTR in code --> <SeekBar @@ -218,6 +234,22 @@ android:layout_marginBottom="@dimen/qs_media_padding" android:layout_marginTop="0dp" /> + <!-- Total time, shown only when scrubbing --> + <!-- The space to the right of the progress bar will either be actionNext or + media_scrubbing_total_time, so they use the same layout constraints. Visibilities of + elements are controlled in code. --> + <TextView + android:id="@+id/media_scrubbing_total_time" + style="@style/MediaPlayer.ScrubbingTime" + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_marginStart="0dp" + android:layout_marginEnd="@dimen/qs_media_action_spacing" + android:layout_marginBottom="@dimen/qs_media_padding" + android:layout_marginTop="0dp" + android:visibility="gone" + /> + <ImageButton android:id="@+id/action0" style="@style/MediaPlayer.SessionAction.Secondary" diff --git a/packages/SystemUI/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml index ee77d210add5..f97bbee3b152 100644 --- a/packages/SystemUI/res/values/styles.xml +++ b/packages/SystemUI/res/values/styles.xml @@ -601,6 +601,12 @@ <item name="android:textColor">?android:attr/textColorSecondary</item> </style> + <style name="MediaPlayer.ScrubbingTime"> + <item name="android:fontFamily">@*android:string/config_headlineFontFamily</item> + <item name="android:textSize">12sp</item> + <item name="android:gravity">center</item> + </style> + <style name="MediaPlayer.Action" parent="@android:style/Widget.Material.Button.Borderless.Small"> <item name="android:background">@drawable/qs_media_light_source</item> <item name="android:tint">?android:attr/textColorPrimary</item> diff --git a/packages/SystemUI/res/xml/media_session_collapsed.xml b/packages/SystemUI/res/xml/media_session_collapsed.xml index f00e03116337..eab7defe1e52 100644 --- a/packages/SystemUI/res/xml/media_session_collapsed.xml +++ b/packages/SystemUI/res/xml/media_session_collapsed.xml @@ -93,6 +93,11 @@ app:layout_constraintTop_toBottomOf="@id/media_seamless" app:layout_constraintLeft_toRightOf="@id/media_action_barrier" /> + <!-- Showing time while scrubbing isn't available in collapsed mode. --> + <Constraint + android:id="@+id/media_scrubbing_elapsed_time" + android:visibility="gone" /> + <Constraint android:id="@+id/media_progress_bar" android:layout_width="0dp" @@ -116,6 +121,11 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@id/media_seamless" /> + <!-- Showing time while scrubbing isn't available in collapsed mode. --> + <Constraint + android:id="@+id/media_scrubbing_total_time" + android:visibility="gone" /> + <Constraint android:id="@+id/action0" android:layout_width="48dp" diff --git a/packages/SystemUI/res/xml/media_session_expanded.xml b/packages/SystemUI/res/xml/media_session_expanded.xml index bec4e7ad540e..522dc686daa3 100644 --- a/packages/SystemUI/res/xml/media_session_expanded.xml +++ b/packages/SystemUI/res/xml/media_session_expanded.xml @@ -68,10 +68,19 @@ The chain is set to "spread" so that the progress bar can be weighted to fill any empty space. --> <Constraint - android:id="@+id/actionPrev" + android:id="@+id/media_scrubbing_elapsed_time" android:layout_width="48dp" android:layout_height="48dp" app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toLeftOf="@id/actionPrev" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintHorizontal_chainStyle="spread" /> + + <Constraint + android:id="@+id/actionPrev" + android:layout_width="48dp" + android:layout_height="48dp" + app:layout_constraintLeft_toRightOf="@id/media_scrubbing_elapsed_time" app:layout_constraintRight_toLeftOf="@id/media_progress_bar" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintHorizontal_chainStyle="spread" /> @@ -90,6 +99,14 @@ android:layout_width="48dp" android:layout_height="48dp" app:layout_constraintLeft_toRightOf="@id/media_progress_bar" + app:layout_constraintRight_toLeftOf="@id/media_scrubbing_total_time" + app:layout_constraintBottom_toBottomOf="parent" /> + + <Constraint + android:id="@+id/media_scrubbing_total_time" + android:layout_width="48dp" + android:layout_height="48dp" + app:layout_constraintLeft_toRightOf="@id/actionNext" app:layout_constraintRight_toLeftOf="@id/action0" app:layout_constraintBottom_toBottomOf="parent" /> @@ -97,7 +114,7 @@ android:id="@+id/action0" android:layout_width="48dp" android:layout_height="48dp" - app:layout_constraintLeft_toRightOf="@id/actionNext" + app:layout_constraintLeft_toRightOf="@id/media_scrubbing_total_time" app:layout_constraintRight_toLeftOf="@id/action1" app:layout_constraintBottom_toBottomOf="parent" /> @@ -115,7 +132,7 @@ android:layout_height="48dp" app:layout_constraintLeft_toRightOf="@id/action1" app:layout_constraintRight_toLeftOf="@id/action3" - app:layout_constraintBottom_toBottomOf="parent"/> + app:layout_constraintBottom_toBottomOf="parent" /> <Constraint android:id="@+id/action3" @@ -123,7 +140,7 @@ android:layout_height="48dp" app:layout_constraintLeft_toRightOf="@id/action2" app:layout_constraintRight_toLeftOf="@id/action4" - app:layout_constraintBottom_toBottomOf="parent"/> + app:layout_constraintBottom_toBottomOf="parent" /> <Constraint android:id="@+id/action4" diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java index 3a727ba7b70c..aac28d1570ff 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java +++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java @@ -60,6 +60,7 @@ import com.android.systemui.animation.ActivityLaunchAnimator; import com.android.systemui.animation.GhostedViewLaunchAnimatorController; import com.android.systemui.broadcast.BroadcastSender; import com.android.systemui.dagger.qualifiers.Background; +import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.media.dialog.MediaOutputDialogFactory; import com.android.systemui.monet.ColorScheme; import com.android.systemui.plugins.ActivityStarter; @@ -108,6 +109,13 @@ public class MediaControlPanel { R.id.actionNext ); + // Buttons that should get hidden when we're scrubbing (they will be replaced with the views + // showing scrubbing time) + private static final List<Integer> SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING = List.of( + R.id.actionPrev, + R.id.actionNext + ); + // Buttons to show in small player when using semantic actions private static final List<Integer> SEMANTIC_ACTIONS_ALL = List.of( R.id.actionPlayPause, @@ -120,6 +128,7 @@ public class MediaControlPanel { private final SeekBarViewModel mSeekBarViewModel; private SeekBarObserver mSeekBarObserver; protected final Executor mBackgroundExecutor; + private final Executor mMainExecutor; private final ActivityStarter mActivityStarter; private final BroadcastSender mBroadcastSender; @@ -127,6 +136,7 @@ public class MediaControlPanel { private MediaViewHolder mMediaViewHolder; private RecommendationViewHolder mRecommendationViewHolder; private String mKey; + private MediaData mMediaData; private MediaViewController mMediaViewController; private MediaSession.Token mToken; private MediaController mController; @@ -147,14 +157,23 @@ public class MediaControlPanel { protected int mSmartspaceId = -1; private String mPackageName; + private boolean mIsScrubbing = false; + + private final SeekBarViewModel.ScrubbingChangeListener mScrubbingChangeListener = + this::setIsScrubbing; + /** * Initialize a new control panel * * @param backgroundExecutor background executor, used for processing artwork + * @param mainExecutor main thread executor, used if we receive callbacks on the background + * thread that then trigger UI changes. * @param activityStarter activity starter */ @Inject - public MediaControlPanel(Context context, @Background Executor backgroundExecutor, + public MediaControlPanel(Context context, + @Background Executor backgroundExecutor, + @Main Executor mainExecutor, ActivityStarter activityStarter, BroadcastSender broadcastSender, MediaViewController mediaViewController, SeekBarViewModel seekBarViewModel, Lazy<MediaDataManager> lazyMediaDataManager, @@ -163,6 +182,7 @@ public class MediaControlPanel { FalsingManager falsingManager, SystemClock systemClock, MediaUiEventLogger logger) { mContext = context; mBackgroundExecutor = backgroundExecutor; + mMainExecutor = mainExecutor; mActivityStarter = activityStarter; mBroadcastSender = broadcastSender; mSeekBarViewModel = seekBarViewModel; @@ -186,6 +206,7 @@ public class MediaControlPanel { public void onDestroy() { if (mSeekBarObserver != null) { mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver); + mSeekBarViewModel.removeScrubbingChangeListener(mScrubbingChangeListener); } mSeekBarViewModel.onDestroy(); mMediaViewController.onDestroy(); @@ -232,6 +253,19 @@ public class MediaControlPanel { mSeekBarViewModel.setListening(listening); } + /** Sets whether the user is touching the seek bar to change the track position. */ + public void setIsScrubbing(boolean isScrubbing) { + if (mMediaData == null || mMediaData.getSemanticActions() == null) { + return; + } + if (isScrubbing == this.mIsScrubbing) { + return; + } + this.mIsScrubbing = isScrubbing; + mMainExecutor.execute(() -> + updateDisplayForScrubbingChange(mMediaData.getSemanticActions())); + } + /** * Get the context * @@ -249,6 +283,7 @@ public class MediaControlPanel { mSeekBarObserver = new SeekBarObserver(vh); mSeekBarViewModel.getProgress().observeForever(mSeekBarObserver); mSeekBarViewModel.attachTouchHandlers(vh.getSeekBar()); + mSeekBarViewModel.setScrubbingChangeListener(mScrubbingChangeListener); mMediaViewController.attach(player, MediaViewController.TYPE.PLAYER); vh.getPlayer().setOnLongClickListener(v -> { @@ -307,6 +342,7 @@ public class MediaControlPanel { return; } mKey = key; + mMediaData = data; MediaSession.Token token = data.getToken(); mPackageName = data.getPackageName(); mUid = data.getAppUid(); @@ -361,6 +397,7 @@ public class MediaControlPanel { bindOutputSwitcherChip(data); bindLongPressMenu(data); bindActionButtons(data); + bindScrubbingTime(data); bindArtworkAndColors(data); // TODO: We don't need to refresh this state constantly, only if the state actually changed @@ -544,6 +581,8 @@ public class MediaControlPanel { seekbar.getThumb().setTintList(textColorList); seekbar.setProgressTintList(textColorList); seekbar.setProgressBackgroundTintList(ColorStateList.valueOf(textTertiary)); + mMediaViewHolder.getScrubbingElapsedTimeView().setTextColor(textColorList); + mMediaViewHolder.getScrubbingTotalTimeView().setTextColor(textColorList); // Action buttons mMediaViewHolder.getActionPlayPause().setBackgroundTintList(accentColorList); @@ -589,10 +628,9 @@ public class MediaControlPanel { } for (int id : SEMANTIC_ACTIONS_ALL) { - boolean showInCompact = SEMANTIC_ACTIONS_COMPACT.contains(id); ImageButton button = mMediaViewHolder.getAction(id); MediaAction action = semanticActions.getActionById(id); - setSemanticButton(button, action, collapsedSet, expandedSet, showInCompact); + setSemanticButton(button, action); } } else { // Hide buttons that only appear for semantic actions @@ -607,12 +645,21 @@ public class MediaControlPanel { int i = 0; for (; i < actions.size(); i++) { boolean showInCompact = actionsWhenCollapsed.contains(i); - setSemanticButton(genericButtons[i], actions.get(i), collapsedSet, - expandedSet, showInCompact); + setGenericButton( + genericButtons[i], + actions.get(i), + collapsedSet, + expandedSet, + showInCompact); } for (; i < 5; i++) { // Hide any unused buttons - setSemanticButton(genericButtons[i], null, collapsedSet, expandedSet, false); + setGenericButton( + genericButtons[i], + /* mediaAction= */ null, + collapsedSet, + expandedSet, + /* showInCompact= */ false); } } expandedSet.setVisibility(R.id.media_progress_bar, getSeekBarVisibility()); @@ -640,8 +687,19 @@ public class MediaControlPanel { return false; } - private void setSemanticButton(final ImageButton button, MediaAction mediaAction, - ConstraintSet collapsedSet, ConstraintSet expandedSet, boolean showInCompact) { + private void setGenericButton( + final ImageButton button, + @Nullable MediaAction mediaAction, + ConstraintSet collapsedSet, + ConstraintSet expandedSet, + boolean showInCompact) { + bindButtonCommon(button, mediaAction); + boolean visible = mediaAction != null; + setVisibleAndAlpha(expandedSet, button.getId(), visible); + setVisibleAndAlpha(collapsedSet, button.getId(), visible && showInCompact); + } + + private void setSemanticButton(final ImageButton button, @Nullable MediaAction mediaAction) { AnimationBindHandler animHandler; if (button.getTag() == null) { animHandler = new AnimationBindHandler(); @@ -651,59 +709,105 @@ public class MediaControlPanel { } animHandler.tryExecute(() -> { - bindSemanticButton(animHandler, button, mediaAction, - collapsedSet, expandedSet, showInCompact); + bindButtonWithAnimations(button, mediaAction, animHandler); + setSemanticButtonVisibleAndAlpha(button.getId(), mediaAction); }); } - private void bindSemanticButton(final AnimationBindHandler animHandler, - final ImageButton button, MediaAction mediaAction, ConstraintSet collapsedSet, - ConstraintSet expandedSet, boolean showInCompact) { - + private void bindButtonWithAnimations( + final ImageButton button, + @Nullable MediaAction mediaAction, + @NonNull AnimationBindHandler animHandler) { if (mediaAction != null) { if (animHandler.updateRebindId(mediaAction.getRebindId())) { animHandler.unregisterAll(); + animHandler.tryRegister(mediaAction.getIcon()); + animHandler.tryRegister(mediaAction.getBackground()); + bindButtonCommon(button, mediaAction); + } + } else { + animHandler.unregisterAll(); + clearButton(button); + } + } - final Drawable icon = mediaAction.getIcon(); - button.setImageDrawable(icon); - button.setContentDescription(mediaAction.getContentDescription()); - final Drawable bgDrawable = mediaAction.getBackground(); - button.setBackground(bgDrawable); - - animHandler.tryRegister(icon); - animHandler.tryRegister(bgDrawable); - - Runnable action = mediaAction.getAction(); - if (action == null) { - button.setEnabled(false); - } else { - button.setEnabled(true); - button.setOnClickListener(v -> { - if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { - mLogger.logTapAction(button.getId(), mUid, mPackageName, mInstanceId); - logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT); - action.run(); - - if (icon instanceof Animatable) { - ((Animatable) icon).start(); - } - if (bgDrawable instanceof Animatable) { - ((Animatable) bgDrawable).start(); - } + private void bindButtonCommon(final ImageButton button, @Nullable MediaAction mediaAction) { + if (mediaAction != null) { + final Drawable icon = mediaAction.getIcon(); + button.setImageDrawable(icon); + button.setContentDescription(mediaAction.getContentDescription()); + final Drawable bgDrawable = mediaAction.getBackground(); + button.setBackground(bgDrawable); + + Runnable action = mediaAction.getAction(); + if (action == null) { + button.setEnabled(false); + } else { + button.setEnabled(true); + button.setOnClickListener(v -> { + if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + mLogger.logTapAction(button.getId(), mUid, mPackageName, mInstanceId); + logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT); + action.run(); + + if (icon instanceof Animatable) { + ((Animatable) icon).start(); } - }); - } + if (bgDrawable instanceof Animatable) { + ((Animatable) bgDrawable).start(); + } + } + }); } } else { - animHandler.unregisterAll(); - button.setImageDrawable(null); - button.setContentDescription(null); - button.setEnabled(false); - button.setBackground(null); + clearButton(button); } + } + + private void clearButton(final ImageButton button) { + button.setImageDrawable(null); + button.setContentDescription(null); + button.setEnabled(false); + button.setBackground(null); + } - setVisibleAndAlpha(collapsedSet, button.getId(), mediaAction != null && showInCompact); - setVisibleAndAlpha(expandedSet, button.getId(), mediaAction != null); + private void setSemanticButtonVisibleAndAlpha( + int buttonId, + MediaAction mediaAction) { + ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout(); + ConstraintSet expandedSet = mMediaViewController.getExpandedLayout(); + boolean showInCompact = SEMANTIC_ACTIONS_COMPACT.contains(buttonId); + boolean hideWhenScrubbing = SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.contains(buttonId); + boolean shouldBeHiddenDueToScrubbing = hideWhenScrubbing && mIsScrubbing; + boolean visible = mediaAction != null && !shouldBeHiddenDueToScrubbing; + + setVisibleAndAlpha(expandedSet, buttonId, visible); + setVisibleAndAlpha(collapsedSet, buttonId, visible && showInCompact); + } + + /** Updates all the views that might change due to a scrubbing state change. */ + // TODO(b/209656742): Handle scenarios where actionPrev and/or actionNext aren't active. + private void updateDisplayForScrubbingChange(@NonNull MediaButton semanticActions) { + // Update visibilities of the scrubbing time views and the scrubbing-dependent buttons. + bindScrubbingTime(mMediaData); + SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.forEach((id) -> + setSemanticButtonVisibleAndAlpha(id, semanticActions.getActionById(id))); + // Trigger a state refresh so that we immediately update visibilities. + mMediaViewController.refreshState(); + } + + private void bindScrubbingTime(MediaData data) { + ConstraintSet expandedSet = mMediaViewController.getExpandedLayout(); + ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout(); + int elapsedTimeId = mMediaViewHolder.getScrubbingElapsedTimeView().getId(); + int totalTimeId = mMediaViewHolder.getScrubbingTotalTimeView().getId(); + + boolean visible = data.getSemanticActions() != null && mIsScrubbing; + setVisibleAndAlpha(expandedSet, elapsedTimeId, visible); + setVisibleAndAlpha(expandedSet, totalTimeId, visible); + // Never show in collapsed + setVisibleAndAlpha(collapsedSet, elapsedTimeId, false); + setVisibleAndAlpha(collapsedSet, totalTimeId, false); } // AnimationBindHandler is responsible for tracking the bound animation state and preventing diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewHolder.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewHolder.kt index e9c88861eeae..34a77f26122c 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaViewHolder.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewHolder.kt @@ -50,8 +50,11 @@ class MediaViewHolder constructor(itemView: View) { // Seekbar views val seekBar = itemView.requireViewById<SeekBar>(R.id.media_progress_bar) - open val elapsedTimeView: TextView? = null - open val totalTimeView: TextView? = null + // These views are only shown while the user is actively scrubbing + val scrubbingElapsedTimeView: TextView = + itemView.requireViewById(R.id.media_scrubbing_elapsed_time) + val scrubbingTotalTimeView: TextView = + itemView.requireViewById(R.id.media_scrubbing_total_time) // Settings screen val longPressText = itemView.requireViewById<TextView>(R.id.remove_text) @@ -165,7 +168,9 @@ class MediaViewHolder constructor(itemView: View) { R.id.action2, R.id.action3, R.id.action4, - R.id.icon + R.id.icon, + R.id.media_scrubbing_elapsed_time, + R.id.media_scrubbing_total_time ) val gutsIds = setOf( R.id.remove_text, diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt index b76f6bb305fe..612a7f92fc33 100644 --- a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt +++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt @@ -70,9 +70,9 @@ class SeekBarObserver( progressDrawable?.animate = false holder.seekBar.thumb.alpha = 0 holder.seekBar.progress = 0 - holder.elapsedTimeView?.text = "" - holder.totalTimeView?.text = "" holder.seekBar.contentDescription = "" + holder.scrubbingElapsedTimeView.text = "" + holder.scrubbingTotalTimeView.text = "" return } @@ -88,13 +88,13 @@ class SeekBarObserver( holder.seekBar.setMax(data.duration) val totalTimeString = DateUtils.formatElapsedTime( data.duration / DateUtils.SECOND_IN_MILLIS) - holder.totalTimeView?.setText(totalTimeString) + holder.scrubbingTotalTimeView.text = totalTimeString data.elapsedTime?.let { holder.seekBar.setProgress(it) val elapsedTimeString = DateUtils.formatElapsedTime( it / DateUtils.SECOND_IN_MILLIS) - holder.elapsedTimeView?.setText(elapsedTimeString) + holder.scrubbingElapsedTimeView.text = elapsedTimeString holder.seekBar.contentDescription = holder.seekBar.context.getString( R.string.controls_media_seekbar_description, diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt index 8c1845ac1ae0..5218492ec4bf 100644 --- a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt @@ -121,12 +121,15 @@ class SeekBarViewModel @Inject constructor( } } + private var scrubbingChangeListener: ScrubbingChangeListener? = null + /** Set to true when the user is touching the seek bar to change the position. */ private var scrubbing = false set(value) { if (field != value) { field = value checkIfPollingNeeded() + scrubbingChangeListener?.onScrubbingChanged(value) _data = _data.copy(scrubbing = value) } } @@ -228,6 +231,7 @@ class SeekBarViewModel @Inject constructor( playbackState = null cancel?.run() cancel = null + scrubbingChangeListener = null } @WorkerThread @@ -265,6 +269,21 @@ class SeekBarViewModel @Inject constructor( bar.setOnTouchListener(SeekBarTouchListener(this, bar)) } + fun setScrubbingChangeListener(listener: ScrubbingChangeListener) { + scrubbingChangeListener = listener + } + + fun removeScrubbingChangeListener(listener: ScrubbingChangeListener) { + if (listener == scrubbingChangeListener) { + scrubbingChangeListener = null + } + } + + /** Listener interface to be notified when the user starts or stops scrubbing. */ + interface ScrubbingChangeListener { + fun onScrubbingChanged(scrubbing: Boolean) + } + private class SeekBarChangeListener( val viewModel: SeekBarViewModel ) : SeekBar.OnSeekBarChangeListener { diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt index 538a9c763438..dc48eb041c07 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt @@ -53,6 +53,7 @@ import com.android.systemui.util.animation.TransitionLayout import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.mockito.KotlinArgumentCaptor import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.withArgCaptor import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import dagger.Lazy @@ -68,6 +69,7 @@ import org.mockito.Mock import org.mockito.Mockito.any import org.mockito.Mockito.mock import org.mockito.Mockito.never +import org.mockito.Mockito.reset import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnit @@ -91,6 +93,7 @@ public class MediaControlPanelTest : SysuiTestCase() { private lateinit var player: MediaControlPanel private lateinit var bgExecutor: FakeExecutor + private lateinit var mainExecutor: FakeExecutor @Mock private lateinit var activityStarter: ActivityStarter @Mock private lateinit var broadcastSender: BroadcastSender @@ -116,8 +119,6 @@ public class MediaControlPanelTest : SysuiTestCase() { private lateinit var seamlessIcon: ImageView private lateinit var seamlessText: TextView private lateinit var seekBar: SeekBar - private lateinit var elapsedTimeView: TextView - private lateinit var totalTimeView: TextView private lateinit var action0: ImageButton private lateinit var action1: ImageButton private lateinit var action2: ImageButton @@ -126,6 +127,8 @@ public class MediaControlPanelTest : SysuiTestCase() { private lateinit var actionPlayPause: ImageButton private lateinit var actionNext: ImageButton private lateinit var actionPrev: ImageButton + private lateinit var scrubbingElapsedTimeView: TextView + private lateinit var scrubbingTotalTimeView: TextView private lateinit var actionsTopBarrier: Barrier @Mock private lateinit var longPressText: TextView @Mock private lateinit var handler: Handler @@ -148,12 +151,25 @@ public class MediaControlPanelTest : SysuiTestCase() { @Before fun setUp() { bgExecutor = FakeExecutor(FakeSystemClock()) + mainExecutor = FakeExecutor(FakeSystemClock()) whenever(mediaViewController.expandedLayout).thenReturn(expandedSet) whenever(mediaViewController.collapsedLayout).thenReturn(collapsedSet) - player = MediaControlPanel(context, bgExecutor, activityStarter, broadcastSender, - mediaViewController, seekBarViewModel, Lazy { mediaDataManager }, - mediaOutputDialogFactory, mediaCarouselController, falsingManager, clock, logger) + player = MediaControlPanel( + context, + bgExecutor, + mainExecutor, + activityStarter, + broadcastSender, + mediaViewController, + seekBarViewModel, + Lazy { mediaDataManager }, + mediaOutputDialogFactory, + mediaCarouselController, + falsingManager, + clock, + logger + ) whenever(seekBarViewModel.progress).thenReturn(seekBarData) // Set up mock views for the players @@ -167,8 +183,6 @@ public class MediaControlPanelTest : SysuiTestCase() { seamlessIcon = ImageView(context) seamlessText = TextView(context) seekBar = SeekBar(context) - elapsedTimeView = TextView(context) - totalTimeView = TextView(context) settings = ImageButton(context) cancel = View(context) cancelText = TextView(context) @@ -184,6 +198,10 @@ public class MediaControlPanelTest : SysuiTestCase() { actionPlayPause = ImageButton(context).also { it.setId(R.id.actionPlayPause) } actionPrev = ImageButton(context).also { it.setId(R.id.actionPrev) } actionNext = ImageButton(context).also { it.setId(R.id.actionNext) } + scrubbingElapsedTimeView = + TextView(context).also { it.setId(R.id.media_scrubbing_elapsed_time) } + scrubbingTotalTimeView = + TextView(context).also { it.setId(R.id.media_scrubbing_total_time) } actionsTopBarrier = Barrier(context).also { @@ -242,6 +260,8 @@ public class MediaControlPanelTest : SysuiTestCase() { whenever(viewHolder.seamlessIcon).thenReturn(seamlessIcon) whenever(viewHolder.seamlessText).thenReturn(seamlessText) whenever(viewHolder.seekBar).thenReturn(seekBar) + whenever(viewHolder.scrubbingElapsedTimeView).thenReturn(scrubbingElapsedTimeView) + whenever(viewHolder.scrubbingTotalTimeView).thenReturn(scrubbingTotalTimeView) // Transition View whenever(view.parent).thenReturn(transitionParent) @@ -366,6 +386,86 @@ public class MediaControlPanelTest : SysuiTestCase() { } @Test + fun bind_notScrubbing_scrubbingViewsGone() { + val icon = context.getDrawable(android.R.drawable.ic_media_play) + val semanticActions = MediaButton( + prevOrCustom = MediaAction(icon, {}, "prev", null), + nextOrCustom = MediaAction(icon, {}, "next", null), + ) + val state = mediaData.copy(semanticActions = semanticActions) + + player.attachPlayer(viewHolder) + player.bindPlayer(state, PACKAGE) + + verify(expandedSet).setVisibility(R.id.media_scrubbing_elapsed_time, ConstraintSet.GONE) + verify(expandedSet).setVisibility(R.id.media_scrubbing_total_time, ConstraintSet.GONE) + } + + @Test + fun setIsScrubbing_noSemanticActions_viewsNotChanged() { + val state = mediaData.copy(semanticActions = null) + player.attachPlayer(viewHolder) + player.bindPlayer(state, PACKAGE) + reset(expandedSet) + + val listener = getScrubbingChangeListener() + + listener.onScrubbingChanged(true) + mainExecutor.runAllReady() + + verify(expandedSet, never()).setVisibility(eq(R.id.media_scrubbing_elapsed_time), anyInt()) + verify(expandedSet, never()).setVisibility(eq(R.id.media_scrubbing_total_time), anyInt()) + } + + @Test + fun setIsScrubbing_true_scrubbingViewsShownAndPrevNextHiddenOnlyInExpanded() { + val icon = context.getDrawable(android.R.drawable.ic_media_play) + val semanticActions = MediaButton( + prevOrCustom = MediaAction(icon, {}, "prev", null), + nextOrCustom = MediaAction(icon, {}, "next", null), + ) + val state = mediaData.copy(semanticActions = semanticActions) + player.attachPlayer(viewHolder) + player.bindPlayer(state, PACKAGE) + reset(expandedSet) + + getScrubbingChangeListener().onScrubbingChanged(true) + mainExecutor.runAllReady() + + // Only in expanded, we should show the scrubbing times and hide prev+next + verify(expandedSet).setVisibility(R.id.media_scrubbing_elapsed_time, ConstraintSet.VISIBLE) + verify(expandedSet).setVisibility(R.id.media_scrubbing_total_time, ConstraintSet.VISIBLE) + verify(expandedSet).setVisibility(R.id.actionPrev, ConstraintSet.GONE) + verify(expandedSet).setVisibility(R.id.actionNext, ConstraintSet.GONE) + } + + @Test + fun setIsScrubbing_trueThenFalse_scrubbingTimeGoneAtEnd() { + val icon = context.getDrawable(android.R.drawable.ic_media_play) + val semanticActions = MediaButton( + prevOrCustom = MediaAction(icon, {}, "prev", null), + nextOrCustom = MediaAction(icon, {}, "next", null), + ) + val state = mediaData.copy(semanticActions = semanticActions) + + player.attachPlayer(viewHolder) + player.bindPlayer(state, PACKAGE) + + getScrubbingChangeListener().onScrubbingChanged(true) + mainExecutor.runAllReady() + reset(expandedSet) + + getScrubbingChangeListener().onScrubbingChanged(false) + mainExecutor.runAllReady() + + // Only in expanded, we should hide the scrubbing times and show prev+next + verify(expandedSet).setVisibility(R.id.media_scrubbing_elapsed_time, ConstraintSet.GONE) + verify(expandedSet).setVisibility(R.id.media_scrubbing_total_time, ConstraintSet.GONE) + verify(expandedSet).setVisibility(R.id.actionPrev, ConstraintSet.VISIBLE) + verify(expandedSet).setVisibility(R.id.actionNext, ConstraintSet.VISIBLE) + } + + @Test fun bindNotificationActions() { val icon = context.getDrawable(android.R.drawable.ic_media_play) val bg = context.getDrawable(R.drawable.qs_media_round_button_background) @@ -780,4 +880,7 @@ public class MediaControlPanelTest : SysuiTestCase() { verify(logger).logSeek(anyInt(), eq(PACKAGE), eq(instanceId)) } + + private fun getScrubbingChangeListener(): SeekBarViewModel.ScrubbingChangeListener = + withArgCaptor { verify(seekBarViewModel).setScrubbingChangeListener(capture()) } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt index e719e841d9a0..c48d84698b3b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt @@ -46,8 +46,8 @@ class SeekBarObserverTest : SysuiTestCase() { @Mock private lateinit var mockHolder: MediaViewHolder @Mock private lateinit var mockSquigglyProgress: SquigglyProgress private lateinit var seekBarView: SeekBar - private lateinit var elapsedTimeView: TextView - private lateinit var totalTimeView: TextView + private lateinit var scrubbingElapsedTimeView: TextView + private lateinit var scrubbingTotalTimeView: TextView @JvmField @Rule val mockitoRule = MockitoJUnit.rule() @@ -60,9 +60,11 @@ class SeekBarObserverTest : SysuiTestCase() { seekBarView = SeekBar(context) seekBarView.progressDrawable = mockSquigglyProgress - elapsedTimeView = TextView(context) - totalTimeView = TextView(context) + scrubbingElapsedTimeView = TextView(context) + scrubbingTotalTimeView = TextView(context) whenever(mockHolder.seekBar).thenReturn(seekBarView) + whenever(mockHolder.scrubbingElapsedTimeView).thenReturn(scrubbingElapsedTimeView) + whenever(mockHolder.scrubbingTotalTimeView).thenReturn(scrubbingTotalTimeView) observer = SeekBarObserver(mockHolder) } @@ -167,4 +169,24 @@ class SeekBarObserverTest : SysuiTestCase() { // THEN progress drawable is not animating verify(mockSquigglyProgress).animate = false } + + @Test + fun seekBarProgress_enabled_timeViewsHaveTime() { + val data = SeekBarViewModel.Progress(enabled = true, true, true, false, 3000, 120000) + + observer.onChanged(data) + + assertThat(scrubbingElapsedTimeView.text).isEqualTo("00:03") + assertThat(scrubbingTotalTimeView.text).isEqualTo("02:00") + } + + @Test + fun seekBarProgress_disabled_timeViewsEmpty() { + val data = SeekBarViewModel.Progress(enabled = false, true, true, false, 3000, 120000) + + observer.onChanged(data) + + assertThat(scrubbingElapsedTimeView.text).isEqualTo("") + assertThat(scrubbingTotalTimeView.text).isEqualTo("") + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt index 20f5e4c19402..afc9c81ff479 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt @@ -324,6 +324,42 @@ public class SeekBarViewModelTest : SysuiTestCase() { } @Test + fun seekStarted_listenerNotified() { + var isScrubbing: Boolean? = null + val listener = object : SeekBarViewModel.ScrubbingChangeListener { + override fun onScrubbingChanged(scrubbing: Boolean) { + isScrubbing = scrubbing + } + } + viewModel.setScrubbingChangeListener(listener) + + viewModel.onSeekStarting() + fakeExecutor.runAllReady() + + assertThat(isScrubbing).isTrue() + } + + @Test + fun seekEnded_listenerNotified() { + var isScrubbing: Boolean? = null + val listener = object : SeekBarViewModel.ScrubbingChangeListener { + override fun onScrubbingChanged(scrubbing: Boolean) { + isScrubbing = scrubbing + } + } + viewModel.setScrubbingChangeListener(listener) + + // Start seeking + viewModel.onSeekStarting() + fakeExecutor.runAllReady() + // End seeking + viewModel.onSeek(15L) + fakeExecutor.runAllReady() + + assertThat(isScrubbing).isFalse() + } + + @Test @Ignore fun onProgressChangedFromUser() { // WHEN user starts dragging the seek bar |