• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.google.android.exoplayer2.ui;
17 
18 import android.animation.ValueAnimator;
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.content.res.TypedArray;
22 import android.graphics.Canvas;
23 import android.graphics.Paint;
24 import android.graphics.Point;
25 import android.graphics.Rect;
26 import android.graphics.drawable.Drawable;
27 import android.os.Bundle;
28 import android.util.AttributeSet;
29 import android.util.DisplayMetrics;
30 import android.view.KeyEvent;
31 import android.view.MotionEvent;
32 import android.view.View;
33 import android.view.ViewParent;
34 import android.view.accessibility.AccessibilityEvent;
35 import android.view.accessibility.AccessibilityNodeInfo;
36 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
37 import androidx.annotation.ColorInt;
38 import androidx.annotation.Nullable;
39 import androidx.annotation.RequiresApi;
40 import com.google.android.exoplayer2.C;
41 import com.google.android.exoplayer2.util.Assertions;
42 import com.google.android.exoplayer2.util.Util;
43 import java.util.Collections;
44 import java.util.Formatter;
45 import java.util.Locale;
46 import java.util.concurrent.CopyOnWriteArraySet;
47 import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
48 
49 /**
50  * A time bar that shows a current position, buffered position, duration and ad markers.
51  *
52  * <p>A DefaultTimeBar can be customized by setting attributes, as outlined below.
53  *
54  * <h3>Attributes</h3>
55  *
56  * The following attributes can be set on a DefaultTimeBar when used in a layout XML file:
57  *
58  * <ul>
59  *   <li><b>{@code bar_height}</b> - Dimension for the height of the time bar.
60  *       <ul>
61  *         <li>Default: {@link #DEFAULT_BAR_HEIGHT_DP}
62  *       </ul>
63  *   <li><b>{@code touch_target_height}</b> - Dimension for the height of the area in which touch
64  *       interactions with the time bar are handled. If no height is specified, this also determines
65  *       the height of the view.
66  *       <ul>
67  *         <li>Default: {@link #DEFAULT_TOUCH_TARGET_HEIGHT_DP}
68  *       </ul>
69  *   <li><b>{@code ad_marker_width}</b> - Dimension for the width of any ad markers shown on the
70  *       bar. Ad markers are superimposed on the time bar to show the times at which ads will play.
71  *       <ul>
72  *         <li>Default: {@link #DEFAULT_AD_MARKER_WIDTH_DP}
73  *       </ul>
74  *   <li><b>{@code scrubber_enabled_size}</b> - Dimension for the diameter of the circular scrubber
75  *       handle when scrubbing is enabled but not in progress. Set to zero if no scrubber handle
76  *       should be shown.
77  *       <ul>
78  *         <li>Default: {@link #DEFAULT_SCRUBBER_ENABLED_SIZE_DP}
79  *       </ul>
80  *   <li><b>{@code scrubber_disabled_size}</b> - Dimension for the diameter of the circular scrubber
81  *       handle when scrubbing isn't enabled. Set to zero if no scrubber handle should be shown.
82  *       <ul>
83  *         <li>Default: {@link #DEFAULT_SCRUBBER_DISABLED_SIZE_DP}
84  *       </ul>
85  *   <li><b>{@code scrubber_dragged_size}</b> - Dimension for the diameter of the circular scrubber
86  *       handle when scrubbing is in progress. Set to zero if no scrubber handle should be shown.
87  *       <ul>
88  *         <li>Default: {@link #DEFAULT_SCRUBBER_DRAGGED_SIZE_DP}
89  *       </ul>
90  *   <li><b>{@code scrubber_drawable}</b> - Optional reference to a drawable to draw for the
91  *       scrubber handle. If set, this overrides the default behavior, which is to draw a circle for
92  *       the scrubber handle.
93  *   <li><b>{@code played_color}</b> - Color for the portion of the time bar representing media
94  *       before the current playback position.
95  *       <ul>
96  *         <li>Corresponding method: {@link #setPlayedColor(int)}
97  *         <li>Default: {@link #DEFAULT_PLAYED_COLOR}
98  *       </ul>
99  *   <li><b>{@code scrubber_color}</b> - Color for the scrubber handle.
100  *       <ul>
101  *         <li>Corresponding method: {@link #setScrubberColor(int)}
102  *         <li>Default: {@link #DEFAULT_SCRUBBER_COLOR}
103  *       </ul>
104  *   <li><b>{@code buffered_color}</b> - Color for the portion of the time bar after the current
105  *       played position up to the current buffered position.
106  *       <ul>
107  *         <li>Corresponding method: {@link #setBufferedColor(int)}
108  *         <li>Default: {@link #DEFAULT_BUFFERED_COLOR}
109  *       </ul>
110  *   <li><b>{@code unplayed_color}</b> - Color for the portion of the time bar after the current
111  *       buffered position.
112  *       <ul>
113  *         <li>Corresponding method: {@link #setUnplayedColor(int)}
114  *         <li>Default: {@link #DEFAULT_UNPLAYED_COLOR}
115  *       </ul>
116  *   <li><b>{@code ad_marker_color}</b> - Color for unplayed ad markers.
117  *       <ul>
118  *         <li>Corresponding method: {@link #setAdMarkerColor(int)}
119  *         <li>Default: {@link #DEFAULT_AD_MARKER_COLOR}
120  *       </ul>
121  *   <li><b>{@code played_ad_marker_color}</b> - Color for played ad markers.
122  *       <ul>
123  *         <li>Corresponding method: {@link #setPlayedAdMarkerColor(int)}
124  *         <li>Default: {@link #DEFAULT_PLAYED_AD_MARKER_COLOR}
125  *       </ul>
126  * </ul>
127  */
128 public class DefaultTimeBar extends View implements TimeBar {
129 
130   /** Default height for the time bar, in dp. */
131   public static final int DEFAULT_BAR_HEIGHT_DP = 4;
132   /** Default height for the touch target, in dp. */
133   public static final int DEFAULT_TOUCH_TARGET_HEIGHT_DP = 26;
134   /** Default width for ad markers, in dp. */
135   public static final int DEFAULT_AD_MARKER_WIDTH_DP = 4;
136   /** Default diameter for the scrubber when enabled, in dp. */
137   public static final int DEFAULT_SCRUBBER_ENABLED_SIZE_DP = 12;
138   /** Default diameter for the scrubber when disabled, in dp. */
139   public static final int DEFAULT_SCRUBBER_DISABLED_SIZE_DP = 0;
140   /** Default diameter for the scrubber when dragged, in dp. */
141   public static final int DEFAULT_SCRUBBER_DRAGGED_SIZE_DP = 16;
142   /** Default color for the played portion of the time bar. */
143   public static final int DEFAULT_PLAYED_COLOR = 0xFFFFFFFF;
144   /** Default color for the unplayed portion of the time bar. */
145   public static final int DEFAULT_UNPLAYED_COLOR = 0x33FFFFFF;
146   /** Default color for the buffered portion of the time bar. */
147   public static final int DEFAULT_BUFFERED_COLOR = 0xCCFFFFFF;
148   /** Default color for the scrubber handle. */
149   public static final int DEFAULT_SCRUBBER_COLOR = 0xFFFFFFFF;
150   /** Default color for ad markers. */
151   public static final int DEFAULT_AD_MARKER_COLOR = 0xB2FFFF00;
152   /** Default color for played ad markers. */
153   public static final int DEFAULT_PLAYED_AD_MARKER_COLOR = 0x33FFFF00;
154 
155   /** The threshold in dps above the bar at which touch events trigger fine scrub mode. */
156   private static final int FINE_SCRUB_Y_THRESHOLD_DP = -50;
157   /** The ratio by which times are reduced in fine scrub mode. */
158   private static final int FINE_SCRUB_RATIO = 3;
159   /**
160    * The time after which the scrubbing listener is notified that scrubbing has stopped after
161    * performing an incremental scrub using key input.
162    */
163   private static final long STOP_SCRUBBING_TIMEOUT_MS = 1000;
164 
165   private static final int DEFAULT_INCREMENT_COUNT = 20;
166 
167   private static final float SHOWN_SCRUBBER_SCALE = 1.0f;
168   private static final float HIDDEN_SCRUBBER_SCALE = 0.0f;
169 
170   /**
171    * The name of the Android SDK view that most closely resembles this custom view. Used as the
172    * class name for accessibility.
173    */
174   private static final String ACCESSIBILITY_CLASS_NAME = "android.widget.SeekBar";
175 
176   private final Rect seekBounds;
177   private final Rect progressBar;
178   private final Rect bufferedBar;
179   private final Rect scrubberBar;
180   private final Paint playedPaint;
181   private final Paint bufferedPaint;
182   private final Paint unplayedPaint;
183   private final Paint adMarkerPaint;
184   private final Paint playedAdMarkerPaint;
185   private final Paint scrubberPaint;
186   @Nullable private final Drawable scrubberDrawable;
187   private final int barHeight;
188   private final int touchTargetHeight;
189   private final int adMarkerWidth;
190   private final int scrubberEnabledSize;
191   private final int scrubberDisabledSize;
192   private final int scrubberDraggedSize;
193   private final int scrubberPadding;
194   private final int fineScrubYThreshold;
195   private final StringBuilder formatBuilder;
196   private final Formatter formatter;
197   private final Runnable stopScrubbingRunnable;
198   private final CopyOnWriteArraySet<OnScrubListener> listeners;
199   private final int[] locationOnScreen;
200   private final Point touchPosition;
201   private final float density;
202 
203   private int keyCountIncrement;
204   private long keyTimeIncrement;
205   private int lastCoarseScrubXPosition;
206   private @MonotonicNonNull Rect lastExclusionRectangle;
207 
208   private ValueAnimator scrubberScalingAnimator;
209   private float scrubberScale;
210   private boolean scrubbing;
211   private long scrubPosition;
212   private long duration;
213   private long position;
214   private long bufferedPosition;
215   private int adGroupCount;
216   @Nullable private long[] adGroupTimesMs;
217   @Nullable private boolean[] playedAdGroups;
218 
DefaultTimeBar(Context context)219   public DefaultTimeBar(Context context) {
220     this(context, null);
221   }
222 
DefaultTimeBar(Context context, @Nullable AttributeSet attrs)223   public DefaultTimeBar(Context context, @Nullable AttributeSet attrs) {
224     this(context, attrs, 0);
225   }
226 
DefaultTimeBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr)227   public DefaultTimeBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
228     this(context, attrs, defStyleAttr, attrs);
229   }
230 
231   // Suppress warnings due to usage of View methods in the constructor.
232   @SuppressWarnings("nullness:method.invocation.invalid")
DefaultTimeBar( Context context, @Nullable AttributeSet attrs, int defStyleAttr, @Nullable AttributeSet timebarAttrs)233   public DefaultTimeBar(
234       Context context,
235       @Nullable AttributeSet attrs,
236       int defStyleAttr,
237       @Nullable AttributeSet timebarAttrs) {
238     super(context, attrs, defStyleAttr);
239     seekBounds = new Rect();
240     progressBar = new Rect();
241     bufferedBar = new Rect();
242     scrubberBar = new Rect();
243     playedPaint = new Paint();
244     bufferedPaint = new Paint();
245     unplayedPaint = new Paint();
246     adMarkerPaint = new Paint();
247     playedAdMarkerPaint = new Paint();
248     scrubberPaint = new Paint();
249     scrubberPaint.setAntiAlias(true);
250     listeners = new CopyOnWriteArraySet<>();
251     locationOnScreen = new int[2];
252     touchPosition = new Point();
253 
254     // Calculate the dimensions and paints for drawn elements.
255     Resources res = context.getResources();
256     DisplayMetrics displayMetrics = res.getDisplayMetrics();
257     density = displayMetrics.density;
258     fineScrubYThreshold = dpToPx(density, FINE_SCRUB_Y_THRESHOLD_DP);
259     int defaultBarHeight = dpToPx(density, DEFAULT_BAR_HEIGHT_DP);
260     int defaultTouchTargetHeight = dpToPx(density, DEFAULT_TOUCH_TARGET_HEIGHT_DP);
261     int defaultAdMarkerWidth = dpToPx(density, DEFAULT_AD_MARKER_WIDTH_DP);
262     int defaultScrubberEnabledSize = dpToPx(density, DEFAULT_SCRUBBER_ENABLED_SIZE_DP);
263     int defaultScrubberDisabledSize = dpToPx(density, DEFAULT_SCRUBBER_DISABLED_SIZE_DP);
264     int defaultScrubberDraggedSize = dpToPx(density, DEFAULT_SCRUBBER_DRAGGED_SIZE_DP);
265     if (timebarAttrs != null) {
266       TypedArray a =
267           context.getTheme().obtainStyledAttributes(timebarAttrs, R.styleable.DefaultTimeBar, 0, 0);
268       try {
269         scrubberDrawable = a.getDrawable(R.styleable.DefaultTimeBar_scrubber_drawable);
270         if (scrubberDrawable != null) {
271           setDrawableLayoutDirection(scrubberDrawable);
272           defaultTouchTargetHeight =
273               Math.max(scrubberDrawable.getMinimumHeight(), defaultTouchTargetHeight);
274         }
275         barHeight = a.getDimensionPixelSize(R.styleable.DefaultTimeBar_bar_height,
276             defaultBarHeight);
277         touchTargetHeight = a.getDimensionPixelSize(R.styleable.DefaultTimeBar_touch_target_height,
278             defaultTouchTargetHeight);
279         adMarkerWidth = a.getDimensionPixelSize(R.styleable.DefaultTimeBar_ad_marker_width,
280             defaultAdMarkerWidth);
281         scrubberEnabledSize = a.getDimensionPixelSize(
282             R.styleable.DefaultTimeBar_scrubber_enabled_size, defaultScrubberEnabledSize);
283         scrubberDisabledSize = a.getDimensionPixelSize(
284             R.styleable.DefaultTimeBar_scrubber_disabled_size, defaultScrubberDisabledSize);
285         scrubberDraggedSize = a.getDimensionPixelSize(
286             R.styleable.DefaultTimeBar_scrubber_dragged_size, defaultScrubberDraggedSize);
287         int playedColor = a.getInt(R.styleable.DefaultTimeBar_played_color, DEFAULT_PLAYED_COLOR);
288         int scrubberColor =
289             a.getInt(R.styleable.DefaultTimeBar_scrubber_color, DEFAULT_SCRUBBER_COLOR);
290         int bufferedColor =
291             a.getInt(R.styleable.DefaultTimeBar_buffered_color, DEFAULT_BUFFERED_COLOR);
292         int unplayedColor =
293             a.getInt(R.styleable.DefaultTimeBar_unplayed_color, DEFAULT_UNPLAYED_COLOR);
294         int adMarkerColor = a.getInt(R.styleable.DefaultTimeBar_ad_marker_color,
295             DEFAULT_AD_MARKER_COLOR);
296         int playedAdMarkerColor =
297             a.getInt(
298                 R.styleable.DefaultTimeBar_played_ad_marker_color, DEFAULT_PLAYED_AD_MARKER_COLOR);
299         playedPaint.setColor(playedColor);
300         scrubberPaint.setColor(scrubberColor);
301         bufferedPaint.setColor(bufferedColor);
302         unplayedPaint.setColor(unplayedColor);
303         adMarkerPaint.setColor(adMarkerColor);
304         playedAdMarkerPaint.setColor(playedAdMarkerColor);
305       } finally {
306         a.recycle();
307       }
308     } else {
309       barHeight = defaultBarHeight;
310       touchTargetHeight = defaultTouchTargetHeight;
311       adMarkerWidth = defaultAdMarkerWidth;
312       scrubberEnabledSize = defaultScrubberEnabledSize;
313       scrubberDisabledSize = defaultScrubberDisabledSize;
314       scrubberDraggedSize = defaultScrubberDraggedSize;
315       playedPaint.setColor(DEFAULT_PLAYED_COLOR);
316       scrubberPaint.setColor(DEFAULT_SCRUBBER_COLOR);
317       bufferedPaint.setColor(DEFAULT_BUFFERED_COLOR);
318       unplayedPaint.setColor(DEFAULT_UNPLAYED_COLOR);
319       adMarkerPaint.setColor(DEFAULT_AD_MARKER_COLOR);
320       playedAdMarkerPaint.setColor(DEFAULT_PLAYED_AD_MARKER_COLOR);
321       scrubberDrawable = null;
322     }
323     formatBuilder = new StringBuilder();
324     formatter = new Formatter(formatBuilder, Locale.getDefault());
325     stopScrubbingRunnable = () -> stopScrubbing(/* canceled= */ false);
326     if (scrubberDrawable != null) {
327       scrubberPadding = (scrubberDrawable.getMinimumWidth() + 1) / 2;
328     } else {
329       scrubberPadding =
330           (Math.max(scrubberDisabledSize, Math.max(scrubberEnabledSize, scrubberDraggedSize)) + 1)
331               / 2;
332     }
333     scrubberScale = 1.0f;
334     scrubberScalingAnimator = new ValueAnimator();
335     scrubberScalingAnimator.addUpdateListener(
336         animation -> {
337           scrubberScale = (float) animation.getAnimatedValue();
338           invalidate(seekBounds);
339         });
340     duration = C.TIME_UNSET;
341     keyTimeIncrement = C.TIME_UNSET;
342     keyCountIncrement = DEFAULT_INCREMENT_COUNT;
343     setFocusable(true);
344     if (getImportantForAccessibility() == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
345       setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
346     }
347   }
348 
349   /** Shows the scrubber handle. */
showScrubber()350   public void showScrubber() {
351     showScrubber(/* showAnimationDurationMs= */ 0);
352   }
353 
354   /**
355    * Shows the scrubber handle with animation.
356    *
357    * @param showAnimationDurationMs The duration for scrubber showing animation.
358    */
showScrubber(long showAnimationDurationMs)359   public void showScrubber(long showAnimationDurationMs) {
360     if (scrubberScalingAnimator.isStarted()) {
361       scrubberScalingAnimator.cancel();
362     }
363     scrubberScalingAnimator.setFloatValues(scrubberScale, SHOWN_SCRUBBER_SCALE);
364     scrubberScalingAnimator.setDuration(showAnimationDurationMs);
365     scrubberScalingAnimator.start();
366   }
367 
368   /** Hides the scrubber handle. */
hideScrubber()369   public void hideScrubber() {
370     hideScrubber(/* hideAnimationDurationMs= */ 0);
371   }
372 
373   /**
374    * Hides the scrubber handle with animation.
375    *
376    * @param hideAnimationDurationMs The duration for scrubber hiding animation.
377    */
hideScrubber(long hideAnimationDurationMs)378   public void hideScrubber(long hideAnimationDurationMs) {
379     if (scrubberScalingAnimator.isStarted()) {
380       scrubberScalingAnimator.cancel();
381     }
382     scrubberScalingAnimator.setFloatValues(scrubberScale, HIDDEN_SCRUBBER_SCALE);
383     scrubberScalingAnimator.setDuration(hideAnimationDurationMs);
384     scrubberScalingAnimator.start();
385   }
386 
387   /**
388    * Sets the color for the portion of the time bar representing media before the playback position.
389    *
390    * @param playedColor The color for the portion of the time bar representing media before the
391    *     playback position.
392    */
setPlayedColor(@olorInt int playedColor)393   public void setPlayedColor(@ColorInt int playedColor) {
394     playedPaint.setColor(playedColor);
395     invalidate(seekBounds);
396   }
397 
398   /**
399    * Sets the color for the scrubber handle.
400    *
401    * @param scrubberColor The color for the scrubber handle.
402    */
setScrubberColor(@olorInt int scrubberColor)403   public void setScrubberColor(@ColorInt int scrubberColor) {
404     scrubberPaint.setColor(scrubberColor);
405     invalidate(seekBounds);
406   }
407 
408   /**
409    * Sets the color for the portion of the time bar after the current played position up to the
410    * current buffered position.
411    *
412    * @param bufferedColor The color for the portion of the time bar after the current played
413    *     position up to the current buffered position.
414    */
setBufferedColor(@olorInt int bufferedColor)415   public void setBufferedColor(@ColorInt int bufferedColor) {
416     bufferedPaint.setColor(bufferedColor);
417     invalidate(seekBounds);
418   }
419 
420   /**
421    * Sets the color for the portion of the time bar after the current played position.
422    *
423    * @param unplayedColor The color for the portion of the time bar after the current played
424    *     position.
425    */
setUnplayedColor(@olorInt int unplayedColor)426   public void setUnplayedColor(@ColorInt int unplayedColor) {
427     unplayedPaint.setColor(unplayedColor);
428     invalidate(seekBounds);
429   }
430 
431   /**
432    * Sets the color for unplayed ad markers.
433    *
434    * @param adMarkerColor The color for unplayed ad markers.
435    */
setAdMarkerColor(@olorInt int adMarkerColor)436   public void setAdMarkerColor(@ColorInt int adMarkerColor) {
437     adMarkerPaint.setColor(adMarkerColor);
438     invalidate(seekBounds);
439   }
440 
441   /**
442    * Sets the color for played ad markers.
443    *
444    * @param playedAdMarkerColor The color for played ad markers.
445    */
setPlayedAdMarkerColor(@olorInt int playedAdMarkerColor)446   public void setPlayedAdMarkerColor(@ColorInt int playedAdMarkerColor) {
447     playedAdMarkerPaint.setColor(playedAdMarkerColor);
448     invalidate(seekBounds);
449   }
450 
451   // TimeBar implementation.
452 
453   @Override
addListener(OnScrubListener listener)454   public void addListener(OnScrubListener listener) {
455     listeners.add(listener);
456   }
457 
458   @Override
removeListener(OnScrubListener listener)459   public void removeListener(OnScrubListener listener) {
460     listeners.remove(listener);
461   }
462 
463   @Override
setKeyTimeIncrement(long time)464   public void setKeyTimeIncrement(long time) {
465     Assertions.checkArgument(time > 0);
466     keyCountIncrement = C.INDEX_UNSET;
467     keyTimeIncrement = time;
468   }
469 
470   @Override
setKeyCountIncrement(int count)471   public void setKeyCountIncrement(int count) {
472     Assertions.checkArgument(count > 0);
473     keyCountIncrement = count;
474     keyTimeIncrement = C.TIME_UNSET;
475   }
476 
477   @Override
setPosition(long position)478   public void setPosition(long position) {
479     this.position = position;
480     setContentDescription(getProgressText());
481     update();
482   }
483 
484   @Override
setBufferedPosition(long bufferedPosition)485   public void setBufferedPosition(long bufferedPosition) {
486     this.bufferedPosition = bufferedPosition;
487     update();
488   }
489 
490   @Override
setDuration(long duration)491   public void setDuration(long duration) {
492     this.duration = duration;
493     if (scrubbing && duration == C.TIME_UNSET) {
494       stopScrubbing(/* canceled= */ true);
495     }
496     update();
497   }
498 
499   @Override
getPreferredUpdateDelay()500   public long getPreferredUpdateDelay() {
501     int timeBarWidthDp = pxToDp(density, progressBar.width());
502     return timeBarWidthDp == 0 || duration == 0 || duration == C.TIME_UNSET
503         ? Long.MAX_VALUE
504         : duration / timeBarWidthDp;
505   }
506 
507   @Override
setAdGroupTimesMs(@ullable long[] adGroupTimesMs, @Nullable boolean[] playedAdGroups, int adGroupCount)508   public void setAdGroupTimesMs(@Nullable long[] adGroupTimesMs, @Nullable boolean[] playedAdGroups,
509       int adGroupCount) {
510     Assertions.checkArgument(adGroupCount == 0
511         || (adGroupTimesMs != null && playedAdGroups != null));
512     this.adGroupCount = adGroupCount;
513     this.adGroupTimesMs = adGroupTimesMs;
514     this.playedAdGroups = playedAdGroups;
515     update();
516   }
517 
518   // View methods.
519 
520   @Override
setEnabled(boolean enabled)521   public void setEnabled(boolean enabled) {
522     super.setEnabled(enabled);
523     if (scrubbing && !enabled) {
524       stopScrubbing(/* canceled= */ true);
525     }
526   }
527 
528   @Override
onDraw(Canvas canvas)529   public void onDraw(Canvas canvas) {
530     canvas.save();
531     drawTimeBar(canvas);
532     drawPlayhead(canvas);
533     canvas.restore();
534   }
535 
536   @Override
onTouchEvent(MotionEvent event)537   public boolean onTouchEvent(MotionEvent event) {
538     if (!isEnabled() || duration <= 0) {
539       return false;
540     }
541     Point touchPosition = resolveRelativeTouchPosition(event);
542     int x = touchPosition.x;
543     int y = touchPosition.y;
544     switch (event.getAction()) {
545       case MotionEvent.ACTION_DOWN:
546         if (isInSeekBar(x, y)) {
547           positionScrubber(x);
548           startScrubbing(getScrubberPosition());
549           update();
550           invalidate();
551           return true;
552         }
553         break;
554       case MotionEvent.ACTION_MOVE:
555         if (scrubbing) {
556           if (y < fineScrubYThreshold) {
557             int relativeX = x - lastCoarseScrubXPosition;
558             positionScrubber(lastCoarseScrubXPosition + relativeX / FINE_SCRUB_RATIO);
559           } else {
560             lastCoarseScrubXPosition = x;
561             positionScrubber(x);
562           }
563           updateScrubbing(getScrubberPosition());
564           update();
565           invalidate();
566           return true;
567         }
568         break;
569       case MotionEvent.ACTION_UP:
570       case MotionEvent.ACTION_CANCEL:
571         if (scrubbing) {
572           stopScrubbing(/* canceled= */ event.getAction() == MotionEvent.ACTION_CANCEL);
573           return true;
574         }
575         break;
576       default:
577         // Do nothing.
578     }
579     return false;
580   }
581 
582   @Override
onKeyDown(int keyCode, KeyEvent event)583   public boolean onKeyDown(int keyCode, KeyEvent event) {
584     if (isEnabled()) {
585       long positionIncrement = getPositionIncrement();
586       switch (keyCode) {
587         case KeyEvent.KEYCODE_DPAD_LEFT:
588           positionIncrement = -positionIncrement;
589           // Fall through.
590         case KeyEvent.KEYCODE_DPAD_RIGHT:
591           if (scrubIncrementally(positionIncrement)) {
592             removeCallbacks(stopScrubbingRunnable);
593             postDelayed(stopScrubbingRunnable, STOP_SCRUBBING_TIMEOUT_MS);
594             return true;
595           }
596           break;
597         case KeyEvent.KEYCODE_DPAD_CENTER:
598         case KeyEvent.KEYCODE_ENTER:
599           if (scrubbing) {
600             stopScrubbing(/* canceled= */ false);
601             return true;
602           }
603           break;
604         default:
605           // Do nothing.
606       }
607     }
608     return super.onKeyDown(keyCode, event);
609   }
610 
611   @Override
onFocusChanged( boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect)612   protected void onFocusChanged(
613       boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) {
614     super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
615     if (scrubbing && !gainFocus) {
616       stopScrubbing(/* canceled= */ false);
617     }
618   }
619 
620   @Override
drawableStateChanged()621   protected void drawableStateChanged() {
622     super.drawableStateChanged();
623     updateDrawableState();
624   }
625 
626   @Override
jumpDrawablesToCurrentState()627   public void jumpDrawablesToCurrentState() {
628     super.jumpDrawablesToCurrentState();
629     if (scrubberDrawable != null) {
630       scrubberDrawable.jumpToCurrentState();
631     }
632   }
633 
634   @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)635   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
636     int heightMode = MeasureSpec.getMode(heightMeasureSpec);
637     int heightSize = MeasureSpec.getSize(heightMeasureSpec);
638     int height = heightMode == MeasureSpec.UNSPECIFIED ? touchTargetHeight
639         : heightMode == MeasureSpec.EXACTLY ? heightSize : Math.min(touchTargetHeight, heightSize);
640     setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), height);
641     updateDrawableState();
642   }
643 
644   @Override
onLayout(boolean changed, int left, int top, int right, int bottom)645   protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
646     int width = right - left;
647     int height = bottom - top;
648     int barY = (height - touchTargetHeight) / 2;
649     int seekLeft = getPaddingLeft();
650     int seekRight = width - getPaddingRight();
651     int progressY = barY + (touchTargetHeight - barHeight) / 2;
652     seekBounds.set(seekLeft, barY, seekRight, barY + touchTargetHeight);
653     progressBar.set(seekBounds.left + scrubberPadding, progressY,
654         seekBounds.right - scrubberPadding, progressY + barHeight);
655     if (Util.SDK_INT >= 29) {
656       setSystemGestureExclusionRectsV29(width, height);
657     }
658     update();
659   }
660 
661   @Override
onRtlPropertiesChanged(int layoutDirection)662   public void onRtlPropertiesChanged(int layoutDirection) {
663     if (scrubberDrawable != null && setDrawableLayoutDirection(scrubberDrawable, layoutDirection)) {
664       invalidate();
665     }
666   }
667 
668   @Override
onInitializeAccessibilityEvent(AccessibilityEvent event)669   public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
670     super.onInitializeAccessibilityEvent(event);
671     if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_SELECTED) {
672       event.getText().add(getProgressText());
673     }
674     event.setClassName(ACCESSIBILITY_CLASS_NAME);
675   }
676 
677   @Override
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)678   public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
679     super.onInitializeAccessibilityNodeInfo(info);
680     info.setClassName(ACCESSIBILITY_CLASS_NAME);
681     info.setContentDescription(getProgressText());
682     if (duration <= 0) {
683       return;
684     }
685     if (Util.SDK_INT >= 21) {
686       info.addAction(AccessibilityAction.ACTION_SCROLL_FORWARD);
687       info.addAction(AccessibilityAction.ACTION_SCROLL_BACKWARD);
688     } else {
689       info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
690       info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
691     }
692   }
693 
694   @Override
performAccessibilityAction(int action, @Nullable Bundle args)695   public boolean performAccessibilityAction(int action, @Nullable Bundle args) {
696     if (super.performAccessibilityAction(action, args)) {
697       return true;
698     }
699     if (duration <= 0) {
700       return false;
701     }
702     if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
703       if (scrubIncrementally(-getPositionIncrement())) {
704         stopScrubbing(/* canceled= */ false);
705       }
706     } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
707       if (scrubIncrementally(getPositionIncrement())) {
708         stopScrubbing(/* canceled= */ false);
709       }
710     } else {
711       return false;
712     }
713     sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
714     return true;
715   }
716 
717   // Internal methods.
718 
startScrubbing(long scrubPosition)719   private void startScrubbing(long scrubPosition) {
720     this.scrubPosition = scrubPosition;
721     scrubbing = true;
722     setPressed(true);
723     ViewParent parent = getParent();
724     if (parent != null) {
725       parent.requestDisallowInterceptTouchEvent(true);
726     }
727     for (OnScrubListener listener : listeners) {
728       listener.onScrubStart(this, scrubPosition);
729     }
730   }
731 
updateScrubbing(long scrubPosition)732   private void updateScrubbing(long scrubPosition) {
733     if (this.scrubPosition == scrubPosition) {
734       return;
735     }
736     this.scrubPosition = scrubPosition;
737     for (OnScrubListener listener : listeners) {
738       listener.onScrubMove(this, scrubPosition);
739     }
740   }
741 
stopScrubbing(boolean canceled)742   private void stopScrubbing(boolean canceled) {
743     removeCallbacks(stopScrubbingRunnable);
744     scrubbing = false;
745     setPressed(false);
746     ViewParent parent = getParent();
747     if (parent != null) {
748       parent.requestDisallowInterceptTouchEvent(false);
749     }
750     invalidate();
751     for (OnScrubListener listener : listeners) {
752       listener.onScrubStop(this, scrubPosition, canceled);
753     }
754   }
755 
756   /**
757    * Incrementally scrubs the position by {@code positionChange}.
758    *
759    * @param positionChange The change in the scrubber position, in milliseconds. May be negative.
760    * @return Returns whether the scrubber position changed.
761    */
scrubIncrementally(long positionChange)762   private boolean scrubIncrementally(long positionChange) {
763     if (duration <= 0) {
764       return false;
765     }
766     long previousPosition = scrubbing ? scrubPosition : position;
767     long scrubPosition = Util.constrainValue(previousPosition + positionChange, 0, duration);
768     if (scrubPosition == previousPosition) {
769       return false;
770     }
771     if (!scrubbing) {
772       startScrubbing(scrubPosition);
773     } else {
774       updateScrubbing(scrubPosition);
775     }
776     update();
777     return true;
778   }
779 
update()780   private void update() {
781     bufferedBar.set(progressBar);
782     scrubberBar.set(progressBar);
783     long newScrubberTime = scrubbing ? scrubPosition : position;
784     if (duration > 0) {
785       int bufferedPixelWidth = (int) ((progressBar.width() * bufferedPosition) / duration);
786       bufferedBar.right = Math.min(progressBar.left + bufferedPixelWidth, progressBar.right);
787       int scrubberPixelPosition = (int) ((progressBar.width() * newScrubberTime) / duration);
788       scrubberBar.right = Math.min(progressBar.left + scrubberPixelPosition, progressBar.right);
789     } else {
790       bufferedBar.right = progressBar.left;
791       scrubberBar.right = progressBar.left;
792     }
793     invalidate(seekBounds);
794   }
795 
positionScrubber(float xPosition)796   private void positionScrubber(float xPosition) {
797     scrubberBar.right = Util.constrainValue((int) xPosition, progressBar.left, progressBar.right);
798   }
799 
resolveRelativeTouchPosition(MotionEvent motionEvent)800   private Point resolveRelativeTouchPosition(MotionEvent motionEvent) {
801     getLocationOnScreen(locationOnScreen);
802     touchPosition.set(
803         ((int) motionEvent.getRawX()) - locationOnScreen[0],
804         ((int) motionEvent.getRawY()) - locationOnScreen[1]);
805     return touchPosition;
806   }
807 
getScrubberPosition()808   private long getScrubberPosition() {
809     if (progressBar.width() <= 0 || duration == C.TIME_UNSET) {
810       return 0;
811     }
812     return (scrubberBar.width() * duration) / progressBar.width();
813   }
814 
isInSeekBar(float x, float y)815   private boolean isInSeekBar(float x, float y) {
816     return seekBounds.contains((int) x, (int) y);
817   }
818 
drawTimeBar(Canvas canvas)819   private void drawTimeBar(Canvas canvas) {
820     int progressBarHeight = progressBar.height();
821     int barTop = progressBar.centerY() - progressBarHeight / 2;
822     int barBottom = barTop + progressBarHeight;
823     if (duration <= 0) {
824       canvas.drawRect(progressBar.left, barTop, progressBar.right, barBottom, unplayedPaint);
825       return;
826     }
827     int bufferedLeft = bufferedBar.left;
828     int bufferedRight = bufferedBar.right;
829     int progressLeft = Math.max(Math.max(progressBar.left, bufferedRight), scrubberBar.right);
830     if (progressLeft < progressBar.right) {
831       canvas.drawRect(progressLeft, barTop, progressBar.right, barBottom, unplayedPaint);
832     }
833     bufferedLeft = Math.max(bufferedLeft, scrubberBar.right);
834     if (bufferedRight > bufferedLeft) {
835       canvas.drawRect(bufferedLeft, barTop, bufferedRight, barBottom, bufferedPaint);
836     }
837     if (scrubberBar.width() > 0) {
838       canvas.drawRect(scrubberBar.left, barTop, scrubberBar.right, barBottom, playedPaint);
839     }
840     if (adGroupCount == 0) {
841       return;
842     }
843     long[] adGroupTimesMs = Assertions.checkNotNull(this.adGroupTimesMs);
844     boolean[] playedAdGroups = Assertions.checkNotNull(this.playedAdGroups);
845     int adMarkerOffset = adMarkerWidth / 2;
846     for (int i = 0; i < adGroupCount; i++) {
847       long adGroupTimeMs = Util.constrainValue(adGroupTimesMs[i], 0, duration);
848       int markerPositionOffset =
849           (int) (progressBar.width() * adGroupTimeMs / duration) - adMarkerOffset;
850       int markerLeft = progressBar.left + Math.min(progressBar.width() - adMarkerWidth,
851           Math.max(0, markerPositionOffset));
852       Paint paint = playedAdGroups[i] ? playedAdMarkerPaint : adMarkerPaint;
853       canvas.drawRect(markerLeft, barTop, markerLeft + adMarkerWidth, barBottom, paint);
854     }
855   }
856 
drawPlayhead(Canvas canvas)857   private void drawPlayhead(Canvas canvas) {
858     if (duration <= 0) {
859       return;
860     }
861     int playheadX = Util.constrainValue(scrubberBar.right, scrubberBar.left, progressBar.right);
862     int playheadY = scrubberBar.centerY();
863     if (scrubberDrawable == null) {
864       int scrubberSize = (scrubbing || isFocused()) ? scrubberDraggedSize
865           : (isEnabled() ? scrubberEnabledSize : scrubberDisabledSize);
866       int playheadRadius = (int) ((scrubberSize * scrubberScale) / 2);
867       canvas.drawCircle(playheadX, playheadY, playheadRadius, scrubberPaint);
868     } else {
869       int scrubberDrawableWidth = (int) (scrubberDrawable.getIntrinsicWidth() * scrubberScale);
870       int scrubberDrawableHeight = (int) (scrubberDrawable.getIntrinsicHeight() * scrubberScale);
871       scrubberDrawable.setBounds(
872           playheadX - scrubberDrawableWidth / 2,
873           playheadY - scrubberDrawableHeight / 2,
874           playheadX + scrubberDrawableWidth / 2,
875           playheadY + scrubberDrawableHeight / 2);
876       scrubberDrawable.draw(canvas);
877     }
878   }
879 
updateDrawableState()880   private void updateDrawableState() {
881     if (scrubberDrawable != null && scrubberDrawable.isStateful()
882         && scrubberDrawable.setState(getDrawableState())) {
883       invalidate();
884     }
885   }
886 
887   @RequiresApi(29)
setSystemGestureExclusionRectsV29(int width, int height)888   private void setSystemGestureExclusionRectsV29(int width, int height) {
889     if (lastExclusionRectangle != null
890         && lastExclusionRectangle.width() == width
891         && lastExclusionRectangle.height() == height) {
892       // Allocating inside onLayout is considered a DrawAllocation lint error, so avoid if possible.
893       return;
894     }
895     lastExclusionRectangle = new Rect(/* left= */ 0, /* top= */ 0, width, height);
896     setSystemGestureExclusionRects(Collections.singletonList(lastExclusionRectangle));
897   }
898 
getProgressText()899   private String getProgressText() {
900     return Util.getStringForTime(formatBuilder, formatter, position);
901   }
902 
getPositionIncrement()903   private long getPositionIncrement() {
904     return keyTimeIncrement == C.TIME_UNSET
905         ? (duration == C.TIME_UNSET ? 0 : (duration / keyCountIncrement)) : keyTimeIncrement;
906   }
907 
setDrawableLayoutDirection(Drawable drawable)908   private boolean setDrawableLayoutDirection(Drawable drawable) {
909     return Util.SDK_INT >= 23 && setDrawableLayoutDirection(drawable, getLayoutDirection());
910   }
911 
setDrawableLayoutDirection(Drawable drawable, int layoutDirection)912   private static boolean setDrawableLayoutDirection(Drawable drawable, int layoutDirection) {
913     return Util.SDK_INT >= 23 && drawable.setLayoutDirection(layoutDirection);
914   }
915 
dpToPx(float density, int dps)916   private static int dpToPx(float density, int dps) {
917     return (int) (dps * density + 0.5f);
918   }
919 
pxToDp(float density, int px)920   private static int pxToDp(float density, int px) {
921     return (int) (px / density);
922   }
923 }
924