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