diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DefaultThumbnailProvider.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DefaultThumbnailProvider.java new file mode 100644 index 0000000000..c06144a1b8 --- /dev/null +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DefaultThumbnailProvider.java @@ -0,0 +1,194 @@ +package com.google.android.exoplayer2.demo; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.AsyncTask; +import android.util.LruCache; +import android.view.View; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.thumbnail.ThumbnailDescription; +import com.google.android.exoplayer2.util.Log; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; + +public class DefaultThumbnailProvider implements ThumbnailProvider { + + private static final String TAG_DEBUG = DefaultThumbnailProvider.class.getSimpleName(); + + private LruCache bitmapCache; + private View parent; + + //dummy bitmap to indicate that a download is already triggered but not finished yet + private final Bitmap dummyBitmap = Bitmap.createBitmap(1,1,Bitmap.Config.ARGB_8888); + @Nullable ExoPlayer exoPlayer; + + public DefaultThumbnailProvider(ExoPlayer exoPlayer, View view) { + this.exoPlayer = exoPlayer; + + this.parent = view; + + final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + final int cacheSize = maxMemory / 4; + bitmapCache = new LruCache(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + return bitmap.getByteCount() / 1024; + } + }; + } + + public Bitmap getThumbnail(long position) { + return getThumbnail(position, true); + } + + private Bitmap getThumbnail(long position, boolean retrigger) { + if (exoPlayer != null) { + Object manifest = exoPlayer.getCurrentManifest(); + + ThumbnailDescription thumbnailDescription = null; + if (manifest instanceof DashManifest) { + DashManifest dashManifest = (DashManifest) manifest; + List thumbnailDescs = dashManifest.getThumbnailDescriptions(position); + //selected thumbnail description with lowest bitrate + for (ThumbnailDescription desc : thumbnailDescs) { + if (thumbnailDescription == null || thumbnailDescription.getBitrate() > desc.getBitrate()) { + thumbnailDescription = desc; + } + } + if (bitmapNotAvailableOrDownloadNotTriggeredYet(thumbnailDescription.getUri())) { + this.initThumbnailSource(thumbnailDescription); + return null; + } + } + + if (retrigger) { + //also download next and prev thumbnails to have a nicer UI user experience + getThumbnail(thumbnailDescription.getStartTimeMs() + thumbnailDescription.getDurationMs(), false); + getThumbnail(thumbnailDescription.getStartTimeMs() - thumbnailDescription.getDurationMs(), false); + } + + return getThumbnailInternal(position, thumbnailDescription); + } + return null; + } + + private boolean bitmapNotAvailableOrDownloadNotTriggeredYet(Uri uri) { + Bitmap tmp = bitmapCache.get(uri.toString()); + if (tmp != null) return false; + return true; + } + + private Bitmap getThumbnailInternal(long position, ThumbnailDescription thumbnailDescription) { + if (thumbnailDescription == null) return null; + + Bitmap thumbnailSource = bitmapCache.get(thumbnailDescription.getUri().toString()); + + if (thumbnailSource == null || thumbnailSource.getWidth() == 1) return null; + + if (position < thumbnailDescription.getStartTimeMs() || position > thumbnailDescription.getStartTimeMs() + thumbnailDescription.getDurationMs()) return null; + + int count = thumbnailDescription.getColumns() * thumbnailDescription.getRows(); + + int durationPerImage = (int)(thumbnailDescription.getDurationMs() / count); + + int imageNumberToUseWithinTile = (int)((position - thumbnailDescription.getStartTimeMs()) / durationPerImage); + + //handle special case if position == duration + if (imageNumberToUseWithinTile > count-1) imageNumberToUseWithinTile = count-1; + + int intRowToUse = (int)(imageNumberToUseWithinTile / thumbnailDescription.getColumns()); + + int intColToUse = imageNumberToUseWithinTile - intRowToUse * thumbnailDescription.getColumns(); + + double thumbnailWidth = (double) thumbnailDescription.getImageWidth() / thumbnailDescription.getColumns(); + double thumbnailHeight = (double) thumbnailDescription.getImageHeight() / thumbnailDescription.getRows(); + + int cropXLeft = (int)Math.round(intColToUse * thumbnailWidth); + int cropYTop = (int)Math.round(intRowToUse * thumbnailHeight); + + if (cropXLeft + thumbnailWidth <= thumbnailSource.getWidth() && cropYTop + thumbnailHeight <= thumbnailSource.getHeight()) { + return Bitmap.createBitmap(thumbnailSource + , cropXLeft, cropYTop, (int) thumbnailWidth, (int) thumbnailHeight); + } + else { + Log.d(TAG_DEBUG, "Image does not have expected (" + thumbnailDescription.getImageWidth() + "x" + thumbnailDescription.getImageHeight() + ") dimensions to crop. Source " + thumbnailDescription.getUri()); + return null; + } + } + + private synchronized void initThumbnailSource(ThumbnailDescription thumbnailDescription){ + String path = thumbnailDescription.getUri().toString(); + if (path == null) return; + + if (bitmapCache.get(path) != null) return; + bitmapCache.put(path, dummyBitmap); + + RetrieveThumbnailImageTask currentTask = new RetrieveThumbnailImageTask(); + currentTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, path); + } + + class RetrieveThumbnailImageTask extends AsyncTask { + + String downloadedUrl; + + RetrieveThumbnailImageTask() { + + } + + @Override + protected void onCancelled() { + super.onCancelled(); + if (downloadedUrl != null) bitmapCache.remove(downloadedUrl); + } + + protected Bitmap doInBackground(String... urls) { + downloadedUrl = urls[0]; + InputStream in =null; + Bitmap thumbnailToDownload=null; + int responseCode = -1; + + try{ + URL url = new URL(downloadedUrl); + if (!isCancelled()) { + HttpURLConnection httpURLConnection = (HttpURLConnection)url.openConnection(); + httpURLConnection.setDoInput(true); + httpURLConnection.connect(); + responseCode = httpURLConnection.getResponseCode(); + if(responseCode == HttpURLConnection.HTTP_OK) + { + if (!isCancelled()) { + in = httpURLConnection.getInputStream(); + if (!isCancelled()) { + thumbnailToDownload = BitmapFactory.decodeStream(in); + } + in.close(); + } + } + } + + } + catch(Exception ex){ + bitmapCache.remove(downloadedUrl); + System.out.println(ex); + } + + return thumbnailToDownload; + } + + protected void onPostExecute(Bitmap downloadedThumbnail) { + if (downloadedThumbnail != null) { + bitmapCache.put(downloadedUrl, downloadedThumbnail); + if (parent != null) parent.invalidate(); + } + else { + bitmapCache.remove(downloadedUrl); + } + } + } + +} diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DefaultThumbnailTimeBar.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DefaultThumbnailTimeBar.java new file mode 100644 index 0000000000..a3e75923e5 --- /dev/null +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DefaultThumbnailTimeBar.java @@ -0,0 +1,1026 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ui.TimeBar; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.Collections; +import java.util.Formatter; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArraySet; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A time bar that shows a current position, buffered position, duration and ad markers. + * + *

A DefaultTimeBar can be customized by setting attributes, as outlined below. + * + *

Attributes

+ * + * The following attributes can be set on a DefaultTimeBar when used in a layout XML file: + * + *
    + *
  • {@code bar_height} - Dimension for the height of the time bar. + *
      + *
    • Default: {@link #DEFAULT_BAR_HEIGHT_DP} + *
    + *
  • {@code touch_target_height} - Dimension for the height of the area in which touch + * interactions with the time bar are handled. If no height is specified, this also determines + * the height of the view. + *
      + *
    • Default: {@link #DEFAULT_TOUCH_TARGET_HEIGHT_DP} + *
    + *
  • {@code ad_marker_width} - Dimension for the width of any ad markers shown on the + * bar. Ad markers are superimposed on the time bar to show the times at which ads will play. + *
      + *
    • Default: {@link #DEFAULT_AD_MARKER_WIDTH_DP} + *
    + *
  • {@code scrubber_enabled_size} - Dimension for the diameter of the circular scrubber + * handle when scrubbing is enabled but not in progress. Set to zero if no scrubber handle + * should be shown. + *
      + *
    • Default: {@link #DEFAULT_SCRUBBER_ENABLED_SIZE_DP} + *
    + *
  • {@code scrubber_disabled_size} - Dimension for the diameter of the circular scrubber + * handle when scrubbing isn't enabled. Set to zero if no scrubber handle should be shown. + *
      + *
    • Default: {@link #DEFAULT_SCRUBBER_DISABLED_SIZE_DP} + *
    + *
  • {@code scrubber_dragged_size} - Dimension for the diameter of the circular scrubber + * handle when scrubbing is in progress. Set to zero if no scrubber handle should be shown. + *
      + *
    • Default: {@link #DEFAULT_SCRUBBER_DRAGGED_SIZE_DP} + *
    + *
  • {@code scrubber_drawable} - Optional reference to a drawable to draw for the + * scrubber handle. If set, this overrides the default behavior, which is to draw a circle for + * the scrubber handle. + *
  • {@code played_color} - Color for the portion of the time bar representing media + * before the current playback position. + *
      + *
    • Corresponding method: {@link #setPlayedColor(int)} + *
    • Default: {@link #DEFAULT_PLAYED_COLOR} + *
    + *
  • {@code scrubber_color} - Color for the scrubber handle. + *
      + *
    • Corresponding method: {@link #setScrubberColor(int)} + *
    • Default: {@link #DEFAULT_SCRUBBER_COLOR} + *
    + *
  • {@code buffered_color} - Color for the portion of the time bar after the current + * played position up to the current buffered position. + *
      + *
    • Corresponding method: {@link #setBufferedColor(int)} + *
    • Default: {@link #DEFAULT_BUFFERED_COLOR} + *
    + *
  • {@code unplayed_color} - Color for the portion of the time bar after the current + * buffered position. + *
      + *
    • Corresponding method: {@link #setUnplayedColor(int)} + *
    • Default: {@link #DEFAULT_UNPLAYED_COLOR} + *
    + *
  • {@code ad_marker_color} - Color for unplayed ad markers. + *
      + *
    • Corresponding method: {@link #setAdMarkerColor(int)} + *
    • Default: {@link #DEFAULT_AD_MARKER_COLOR} + *
    + *
  • {@code played_ad_marker_color} - Color for played ad markers. + *
      + *
    • Corresponding method: {@link #setPlayedAdMarkerColor(int)} + *
    • Default: {@link #DEFAULT_PLAYED_AD_MARKER_COLOR} + *
    + *
+ */ +public class DefaultThumbnailTimeBar extends View implements TimeBar { + + /** Default height for the time bar, in dp. */ + public static final int DEFAULT_BAR_HEIGHT_DP = 4; + /** Default height for the touch target, in dp. */ + public static final int DEFAULT_TOUCH_TARGET_HEIGHT_DP = 26; + /** Default width for ad markers, in dp. */ + public static final int DEFAULT_AD_MARKER_WIDTH_DP = 4; + /** Default diameter for the scrubber when enabled, in dp. */ + public static final int DEFAULT_SCRUBBER_ENABLED_SIZE_DP = 12; + /** Default diameter for the scrubber when disabled, in dp. */ + public static final int DEFAULT_SCRUBBER_DISABLED_SIZE_DP = 0; + /** Default diameter for the scrubber when dragged, in dp. */ + public static final int DEFAULT_SCRUBBER_DRAGGED_SIZE_DP = 16; + /** Default color for the played portion of the time bar. */ + public static final int DEFAULT_PLAYED_COLOR = 0xFFFFFFFF; + /** Default color for the unplayed portion of the time bar. */ + public static final int DEFAULT_UNPLAYED_COLOR = 0x33FFFFFF; + /** Default color for the buffered portion of the time bar. */ + public static final int DEFAULT_BUFFERED_COLOR = 0xCCFFFFFF; + /** Default color for the scrubber handle. */ + public static final int DEFAULT_SCRUBBER_COLOR = 0xFFFFFFFF; + /** Default color for ad markers. */ + public static final int DEFAULT_AD_MARKER_COLOR = 0xB2FFFF00; + /** Default color for played ad markers. */ + public static final int DEFAULT_PLAYED_AD_MARKER_COLOR = 0x33FFFF00; + + /** Vertical gravity for progress bar to be located at the center in the view. */ + public static final int BAR_GRAVITY_CENTER = 0; + /** Vertical gravity for progress bar to be located at the bottom in the view. */ + public static final int BAR_GRAVITY_BOTTOM = 1; + + /** The threshold in dps above the bar at which touch events trigger fine scrub mode. */ + private static final int FINE_SCRUB_Y_THRESHOLD_DP = -50; + /** The ratio by which times are reduced in fine scrub mode. */ + private static final int FINE_SCRUB_RATIO = 3; + /** + * The time after which the scrubbing listener is notified that scrubbing has stopped after + * performing an incremental scrub using key input. + */ + private static final long STOP_SCRUBBING_TIMEOUT_MS = 1000; + + private static final int DEFAULT_INCREMENT_COUNT = 20; + + private static final float SHOWN_SCRUBBER_SCALE = 1.0f; + private static final float HIDDEN_SCRUBBER_SCALE = 0.0f; + + /** + * The name of the Android SDK view that most closely resembles this custom view. Used as the + * class name for accessibility. + */ + private static final String ACCESSIBILITY_CLASS_NAME = "android.widget.SeekBar"; + + private final Rect seekBounds; + private final Rect progressBar; + private final Rect bufferedBar; + private final Rect scrubberBar; + private final Paint playedPaint; + private final Paint bufferedPaint; + private final Paint unplayedPaint; + private final Paint adMarkerPaint; + private final Paint playedAdMarkerPaint; + private final Paint scrubberPaint; + @Nullable private final Drawable scrubberDrawable; + private final int barHeight; + private final int touchTargetHeight; + private final int barGravity; + private final int adMarkerWidth; + private final int scrubberEnabledSize; + private final int scrubberDisabledSize; + private final int scrubberDraggedSize; + private final int scrubberPadding; + private final int fineScrubYThreshold; + private final StringBuilder formatBuilder; + private final Formatter formatter; + private final Runnable stopScrubbingRunnable; + private final CopyOnWriteArraySet listeners; + private final Point touchPosition; + private final float density; + + private int keyCountIncrement; + private long keyTimeIncrement; + private int lastCoarseScrubXPosition; + private @MonotonicNonNull Rect lastExclusionRectangle; + + private ValueAnimator scrubberScalingAnimator; + private float scrubberScale; + private boolean scrubberPaddingDisabled; + private boolean scrubbing; + private long scrubPosition; + private long duration; + private long position; + private long bufferedPosition; + private int adGroupCount; + @Nullable private long[] adGroupTimesMs; + @Nullable private boolean[] playedAdGroups; + + private ThumbnailProvider thumbnailUtils; + //TODO put in ressource file + int targetThumbnailHeightInDp = 80; + + public DefaultThumbnailTimeBar(Context context) { + this(context, null); + } + + public DefaultThumbnailTimeBar(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public DefaultThumbnailTimeBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, attrs); + } + + public DefaultThumbnailTimeBar( + Context context, + @Nullable AttributeSet attrs, + int defStyleAttr, + @Nullable AttributeSet timebarAttrs) { + this(context, attrs, defStyleAttr, timebarAttrs, 0); + } + + // Suppress warnings due to usage of View methods in the constructor. + @SuppressWarnings("nullness:method.invocation") + public DefaultThumbnailTimeBar( + Context context, + @Nullable AttributeSet attrs, + int defStyleAttr, + @Nullable AttributeSet timebarAttrs, + int defStyleRes) { + super(context, attrs, defStyleAttr); + seekBounds = new Rect(); + progressBar = new Rect(); + bufferedBar = new Rect(); + scrubberBar = new Rect(); + playedPaint = new Paint(); + bufferedPaint = new Paint(); + unplayedPaint = new Paint(); + adMarkerPaint = new Paint(); + playedAdMarkerPaint = new Paint(); + scrubberPaint = new Paint(); + scrubberPaint.setAntiAlias(true); + listeners = new CopyOnWriteArraySet<>(); + touchPosition = new Point(); + + // Calculate the dimensions and paints for drawn elements. + Resources res = context.getResources(); + DisplayMetrics displayMetrics = res.getDisplayMetrics(); + density = displayMetrics.density; + fineScrubYThreshold = dpToPx(density, FINE_SCRUB_Y_THRESHOLD_DP); + int defaultBarHeight = dpToPx(density, DEFAULT_BAR_HEIGHT_DP); + int defaultTouchTargetHeight = dpToPx(density, DEFAULT_TOUCH_TARGET_HEIGHT_DP); + int defaultAdMarkerWidth = dpToPx(density, DEFAULT_AD_MARKER_WIDTH_DP); + int defaultScrubberEnabledSize = dpToPx(density, DEFAULT_SCRUBBER_ENABLED_SIZE_DP); + int defaultScrubberDisabledSize = dpToPx(density, DEFAULT_SCRUBBER_DISABLED_SIZE_DP); + int defaultScrubberDraggedSize = dpToPx(density, DEFAULT_SCRUBBER_DRAGGED_SIZE_DP); + if (timebarAttrs != null) { + TypedArray a = + context + .getTheme() + .obtainStyledAttributes( + timebarAttrs, com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar, defStyleAttr, defStyleRes); + try { + scrubberDrawable = a.getDrawable(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_scrubber_drawable); + if (scrubberDrawable != null) { + setDrawableLayoutDirection(scrubberDrawable); + defaultTouchTargetHeight = + Math.max(scrubberDrawable.getMinimumHeight(), defaultTouchTargetHeight); + } + barHeight = + a.getDimensionPixelSize(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_bar_height, defaultBarHeight); + touchTargetHeight = + a.getDimensionPixelSize( + com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_touch_target_height, defaultTouchTargetHeight); + barGravity = a.getInt(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_bar_gravity, BAR_GRAVITY_CENTER); + adMarkerWidth = + a.getDimensionPixelSize( + com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_ad_marker_width, defaultAdMarkerWidth); + scrubberEnabledSize = + a.getDimensionPixelSize( + com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_scrubber_enabled_size, defaultScrubberEnabledSize); + scrubberDisabledSize = + a.getDimensionPixelSize( + com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_scrubber_disabled_size, defaultScrubberDisabledSize); + scrubberDraggedSize = + a.getDimensionPixelSize( + com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_scrubber_dragged_size, defaultScrubberDraggedSize); + int playedColor = a.getInt(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_played_color, DEFAULT_PLAYED_COLOR); + int scrubberColor = + a.getInt(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_scrubber_color, DEFAULT_SCRUBBER_COLOR); + int bufferedColor = + a.getInt(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_buffered_color, DEFAULT_BUFFERED_COLOR); + int unplayedColor = + a.getInt(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_unplayed_color, DEFAULT_UNPLAYED_COLOR); + int adMarkerColor = + a.getInt(com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_ad_marker_color, DEFAULT_AD_MARKER_COLOR); + int playedAdMarkerColor = + a.getInt( + com.google.android.exoplayer2.ui.R.styleable.DefaultTimeBar_played_ad_marker_color, DEFAULT_PLAYED_AD_MARKER_COLOR); + playedPaint.setColor(playedColor); + scrubberPaint.setColor(scrubberColor); + bufferedPaint.setColor(bufferedColor); + unplayedPaint.setColor(unplayedColor); + adMarkerPaint.setColor(adMarkerColor); + playedAdMarkerPaint.setColor(playedAdMarkerColor); + } finally { + a.recycle(); + } + } else { + barHeight = defaultBarHeight; + touchTargetHeight = defaultTouchTargetHeight; + barGravity = BAR_GRAVITY_CENTER; + adMarkerWidth = defaultAdMarkerWidth; + scrubberEnabledSize = defaultScrubberEnabledSize; + scrubberDisabledSize = defaultScrubberDisabledSize; + scrubberDraggedSize = defaultScrubberDraggedSize; + playedPaint.setColor(DEFAULT_PLAYED_COLOR); + scrubberPaint.setColor(DEFAULT_SCRUBBER_COLOR); + bufferedPaint.setColor(DEFAULT_BUFFERED_COLOR); + unplayedPaint.setColor(DEFAULT_UNPLAYED_COLOR); + adMarkerPaint.setColor(DEFAULT_AD_MARKER_COLOR); + playedAdMarkerPaint.setColor(DEFAULT_PLAYED_AD_MARKER_COLOR); + scrubberDrawable = null; + } + formatBuilder = new StringBuilder(); + formatter = new Formatter(formatBuilder, Locale.getDefault()); + stopScrubbingRunnable = () -> stopScrubbing(/* canceled= */ false); + if (scrubberDrawable != null) { + scrubberPadding = (scrubberDrawable.getMinimumWidth() + 1) / 2; + } else { + scrubberPadding = + (Math.max(scrubberDisabledSize, Math.max(scrubberEnabledSize, scrubberDraggedSize)) + 1) + / 2; + } + scrubberScale = 1.0f; + scrubberScalingAnimator = new ValueAnimator(); + scrubberScalingAnimator.addUpdateListener( + animation -> { + scrubberScale = (float) animation.getAnimatedValue(); + invalidate(seekBounds); + }); + duration = C.TIME_UNSET; + keyTimeIncrement = C.TIME_UNSET; + keyCountIncrement = DEFAULT_INCREMENT_COUNT; + setFocusable(true); + if (getImportantForAccessibility() == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + } + + public void setThumbnailUtils(ThumbnailProvider thumbnailUtils) { + this.thumbnailUtils = thumbnailUtils; + } + + /** Shows the scrubber handle. */ + public void showScrubber() { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberPaddingDisabled = false; + scrubberScale = 1; + invalidate(seekBounds); + } + + /** + * Shows the scrubber handle with animation. + * + * @param showAnimationDurationMs The duration for scrubber showing animation. + */ + public void showScrubber(long showAnimationDurationMs) { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberPaddingDisabled = false; + scrubberScalingAnimator.setFloatValues(scrubberScale, SHOWN_SCRUBBER_SCALE); + scrubberScalingAnimator.setDuration(showAnimationDurationMs); + scrubberScalingAnimator.start(); + } + + /** Hides the scrubber handle. */ + public void hideScrubber(boolean disableScrubberPadding) { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberPaddingDisabled = disableScrubberPadding; + scrubberScale = 0; + invalidate(seekBounds); + } + + /** + * Hides the scrubber handle with animation. + * + * @param hideAnimationDurationMs The duration for scrubber hiding animation. + */ + public void hideScrubber(long hideAnimationDurationMs) { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberScalingAnimator.setFloatValues(scrubberScale, HIDDEN_SCRUBBER_SCALE); + scrubberScalingAnimator.setDuration(hideAnimationDurationMs); + scrubberScalingAnimator.start(); + } + + /** + * Sets the color for the portion of the time bar representing media before the playback position. + * + * @param playedColor The color for the portion of the time bar representing media before the + * playback position. + */ + public void setPlayedColor(@ColorInt int playedColor) { + playedPaint.setColor(playedColor); + invalidate(seekBounds); + } + + /** + * Sets the color for the scrubber handle. + * + * @param scrubberColor The color for the scrubber handle. + */ + public void setScrubberColor(@ColorInt int scrubberColor) { + scrubberPaint.setColor(scrubberColor); + invalidate(seekBounds); + } + + /** + * Sets the color for the portion of the time bar after the current played position up to the + * current buffered position. + * + * @param bufferedColor The color for the portion of the time bar after the current played + * position up to the current buffered position. + */ + public void setBufferedColor(@ColorInt int bufferedColor) { + bufferedPaint.setColor(bufferedColor); + invalidate(seekBounds); + } + + /** + * Sets the color for the portion of the time bar after the current played position. + * + * @param unplayedColor The color for the portion of the time bar after the current played + * position. + */ + public void setUnplayedColor(@ColorInt int unplayedColor) { + unplayedPaint.setColor(unplayedColor); + invalidate(seekBounds); + } + + /** + * Sets the color for unplayed ad markers. + * + * @param adMarkerColor The color for unplayed ad markers. + */ + public void setAdMarkerColor(@ColorInt int adMarkerColor) { + adMarkerPaint.setColor(adMarkerColor); + invalidate(seekBounds); + } + + /** + * Sets the color for played ad markers. + * + * @param playedAdMarkerColor The color for played ad markers. + */ + public void setPlayedAdMarkerColor(@ColorInt int playedAdMarkerColor) { + playedAdMarkerPaint.setColor(playedAdMarkerColor); + invalidate(seekBounds); + } + + // TimeBar implementation. + + @Override + public void addListener(OnScrubListener listener) { + Assertions.checkNotNull(listener); + listeners.add(listener); + } + + @Override + public void removeListener(OnScrubListener listener) { + listeners.remove(listener); + } + + @Override + public void setKeyTimeIncrement(long time) { + Assertions.checkArgument(time > 0); + keyCountIncrement = C.INDEX_UNSET; + keyTimeIncrement = time; + } + + @Override + public void setKeyCountIncrement(int count) { + Assertions.checkArgument(count > 0); + keyCountIncrement = count; + keyTimeIncrement = C.TIME_UNSET; + } + + @Override + public void setPosition(long position) { + if (this.position == position) { + return; + } + this.position = position; + setContentDescription(getProgressText()); + update(); + } + + @Override + public void setBufferedPosition(long bufferedPosition) { + if (this.bufferedPosition == bufferedPosition) { + return; + } + this.bufferedPosition = bufferedPosition; + update(); + } + + @Override + public void setDuration(long duration) { + if (this.duration == duration) { + return; + } + this.duration = duration; + if (scrubbing && duration == C.TIME_UNSET) { + stopScrubbing(/* canceled= */ true); + } + update(); + } + + @Override + public long getPreferredUpdateDelay() { + int timeBarWidthDp = pxToDp(density, progressBar.width()); + return timeBarWidthDp == 0 || duration == 0 || duration == C.TIME_UNSET + ? Long.MAX_VALUE + : duration / timeBarWidthDp; + } + + @Override + public void setAdGroupTimesMs( + @Nullable long[] adGroupTimesMs, @Nullable boolean[] playedAdGroups, int adGroupCount) { + Assertions.checkArgument( + adGroupCount == 0 || (adGroupTimesMs != null && playedAdGroups != null)); + this.adGroupCount = adGroupCount; + this.adGroupTimesMs = adGroupTimesMs; + this.playedAdGroups = playedAdGroups; + update(); + } + + // View methods. + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + if (scrubbing && !enabled) { + stopScrubbing(/* canceled= */ true); + } + } + + @Override + public void onDraw(Canvas canvas) { + canvas.save(); + drawTimeBar(canvas); + drawPlayhead(canvas); + canvas.restore(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled() || duration <= 0) { + return false; + } + Point touchPosition = resolveRelativeTouchPosition(event); + int x = touchPosition.x; + int y = touchPosition.y; + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (isInSeekBar(x, y)) { + positionScrubber(x); + startScrubbing(getScrubberPosition()); + update(); + invalidate(); + return true; + } + break; + case MotionEvent.ACTION_MOVE: + if (scrubbing) { + if (y < fineScrubYThreshold) { + int relativeX = x - lastCoarseScrubXPosition; + positionScrubber(lastCoarseScrubXPosition + relativeX / FINE_SCRUB_RATIO); + } else { + lastCoarseScrubXPosition = x; + positionScrubber(x); + } + updateScrubbing(getScrubberPosition()); + update(); + invalidate(); + return true; + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (scrubbing) { + stopScrubbing(/* canceled= */ event.getAction() == MotionEvent.ACTION_CANCEL); + return true; + } + break; + default: + // Do nothing. + } + return false; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (isEnabled()) { + long positionIncrement = getPositionIncrement(); + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_LEFT: + positionIncrement = -positionIncrement; + // Fall through. + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (scrubIncrementally(positionIncrement)) { + removeCallbacks(stopScrubbingRunnable); + postDelayed(stopScrubbingRunnable, STOP_SCRUBBING_TIMEOUT_MS); + return true; + } + break; + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_ENTER: + if (scrubbing) { + stopScrubbing(/* canceled= */ false); + return true; + } + break; + default: + // Do nothing. + } + } + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onFocusChanged( + boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + if (scrubbing && !gainFocus) { + stopScrubbing(/* canceled= */ false); + } + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + updateDrawableState(); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (scrubberDrawable != null) { + scrubberDrawable.jumpToCurrentState(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + int height = + heightMode == MeasureSpec.UNSPECIFIED + ? touchTargetHeight + : heightMode == MeasureSpec.EXACTLY + ? heightSize + : Math.min(touchTargetHeight, heightSize); + setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), height); + updateDrawableState(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + int width = right - left; + int height = bottom - top; + int seekLeft = getPaddingLeft(); + int seekRight = width - getPaddingRight(); + int seekBoundsY; + int progressBarY; + int scrubberPadding = scrubberPaddingDisabled ? 0 : this.scrubberPadding; + if (barGravity == BAR_GRAVITY_BOTTOM) { + seekBoundsY = height - getPaddingBottom() - touchTargetHeight; + progressBarY = + height - getPaddingBottom() - barHeight - Math.max(scrubberPadding - (barHeight / 2), 0); + } else { + seekBoundsY = (height - touchTargetHeight) / 2; + progressBarY = (height - barHeight) / 2; + } + seekBounds.set(seekLeft, seekBoundsY, seekRight, seekBoundsY + touchTargetHeight); + progressBar.set( + seekBounds.left + scrubberPadding, + progressBarY, + seekBounds.right - scrubberPadding, + progressBarY + barHeight); + if (Util.SDK_INT >= 29) { + setSystemGestureExclusionRectsV29(width, height); + } + update(); + } + + @Override + public void onRtlPropertiesChanged(int layoutDirection) { + if (scrubberDrawable != null && setDrawableLayoutDirection(scrubberDrawable, layoutDirection)) { + invalidate(); + } + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_SELECTED) { + event.getText().add(getProgressText()); + } + event.setClassName(ACCESSIBILITY_CLASS_NAME); + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName(ACCESSIBILITY_CLASS_NAME); + info.setContentDescription(getProgressText()); + if (duration <= 0) { + return; + } + if (Util.SDK_INT >= 21) { + info.addAction(AccessibilityAction.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityAction.ACTION_SCROLL_BACKWARD); + } else { + info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); + } + } + + @Override + public boolean performAccessibilityAction(int action, @Nullable Bundle args) { + if (super.performAccessibilityAction(action, args)) { + return true; + } + if (duration <= 0) { + return false; + } + if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { + if (scrubIncrementally(-getPositionIncrement())) { + stopScrubbing(/* canceled= */ false); + } + } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { + if (scrubIncrementally(getPositionIncrement())) { + stopScrubbing(/* canceled= */ false); + } + } else { + return false; + } + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); + return true; + } + + // Internal methods. + + private void startScrubbing(long scrubPosition) { + this.scrubPosition = scrubPosition; + scrubbing = true; + setPressed(true); + ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + for (OnScrubListener listener : listeners) { + listener.onScrubStart(this, scrubPosition); + } + } + + private void updateScrubbing(long scrubPosition) { + if (this.scrubPosition == scrubPosition) { + return; + } + this.scrubPosition = scrubPosition; + for (OnScrubListener listener : listeners) { + listener.onScrubMove(this, scrubPosition); + } + } + + private void stopScrubbing(boolean canceled) { + removeCallbacks(stopScrubbingRunnable); + scrubbing = false; + setPressed(false); + ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(false); + } + invalidate(); + for (OnScrubListener listener : listeners) { + listener.onScrubStop(this, scrubPosition, canceled); + } + } + + /** + * Incrementally scrubs the position by {@code positionChange}. + * + * @param positionChange The change in the scrubber position, in milliseconds. May be negative. + * @return Returns whether the scrubber position changed. + */ + private boolean scrubIncrementally(long positionChange) { + if (duration <= 0) { + return false; + } + long previousPosition = scrubbing ? scrubPosition : position; + long scrubPosition = Util.constrainValue(previousPosition + positionChange, 0, duration); + if (scrubPosition == previousPosition) { + return false; + } + if (!scrubbing) { + startScrubbing(scrubPosition); + } else { + updateScrubbing(scrubPosition); + } + update(); + return true; + } + + private void update() { + bufferedBar.set(progressBar); + scrubberBar.set(progressBar); + long newScrubberTime = scrubbing ? scrubPosition : position; + if (duration > 0) { + int bufferedPixelWidth = (int) ((progressBar.width() * bufferedPosition) / duration); + bufferedBar.right = Math.min(progressBar.left + bufferedPixelWidth, progressBar.right); + int scrubberPixelPosition = (int) ((progressBar.width() * newScrubberTime) / duration); + scrubberBar.right = Math.min(progressBar.left + scrubberPixelPosition, progressBar.right); + } else { + bufferedBar.right = progressBar.left; + scrubberBar.right = progressBar.left; + } + invalidate(seekBounds); + } + + private void positionScrubber(float xPosition) { + scrubberBar.right = Util.constrainValue((int) xPosition, progressBar.left, progressBar.right); + } + + private Point resolveRelativeTouchPosition(MotionEvent motionEvent) { + touchPosition.set((int) motionEvent.getX(), (int) motionEvent.getY()); + return touchPosition; + } + + private long getScrubberPosition() { + if (progressBar.width() <= 0 || duration == C.TIME_UNSET) { + return 0; + } + return (scrubberBar.width() * duration) / progressBar.width(); + } + + private boolean isInSeekBar(float x, float y) { + return seekBounds.contains((int) x, (int) y); + } + + private void drawTimeBar(Canvas canvas) { + int progressBarHeight = progressBar.height(); + int barTop = progressBar.centerY() - progressBarHeight / 2; + int barBottom = barTop + progressBarHeight; + if (duration <= 0) { + canvas.drawRect(progressBar.left, barTop, progressBar.right, barBottom, unplayedPaint); + return; + } + int bufferedLeft = bufferedBar.left; + int bufferedRight = bufferedBar.right; + int progressLeft = Math.max(Math.max(progressBar.left, bufferedRight), scrubberBar.right); + if (progressLeft < progressBar.right) { + canvas.drawRect(progressLeft, barTop, progressBar.right, barBottom, unplayedPaint); + } + bufferedLeft = Math.max(bufferedLeft, scrubberBar.right); + if (bufferedRight > bufferedLeft) { + canvas.drawRect(bufferedLeft, barTop, bufferedRight, barBottom, bufferedPaint); + } + if (scrubberBar.width() > 0) { + canvas.drawRect(scrubberBar.left, barTop, scrubberBar.right, barBottom, playedPaint); + } + if (adGroupCount == 0) { + return; + } + long[] adGroupTimesMs = Assertions.checkNotNull(this.adGroupTimesMs); + boolean[] playedAdGroups = Assertions.checkNotNull(this.playedAdGroups); + int adMarkerOffset = adMarkerWidth / 2; + for (int i = 0; i < adGroupCount; i++) { + long adGroupTimeMs = Util.constrainValue(adGroupTimesMs[i], 0, duration); + int markerPositionOffset = + (int) (progressBar.width() * adGroupTimeMs / duration) - adMarkerOffset; + int markerLeft = + progressBar.left + + Math.min(progressBar.width() - adMarkerWidth, Math.max(0, markerPositionOffset)); + Paint paint = playedAdGroups[i] ? playedAdMarkerPaint : adMarkerPaint; + canvas.drawRect(markerLeft, barTop, markerLeft + adMarkerWidth, barBottom, paint); + } + } + + private void drawPlayhead(Canvas canvas) { + if (duration <= 0) { + return; + } + int playheadX = Util.constrainValue(scrubberBar.right, scrubberBar.left, progressBar.right); + int playheadY = scrubberBar.centerY(); + if (scrubberDrawable == null) { + int scrubberSize = + (scrubbing || isFocused()) + ? scrubberDraggedSize + : (isEnabled() ? scrubberEnabledSize : scrubberDisabledSize); + int playheadRadius = (int) ((scrubberSize * scrubberScale) / 2); + canvas.drawCircle(playheadX, playheadY, playheadRadius, scrubberPaint); + + if (scrubbing) drawThumbnail(canvas, playheadRadius, playheadX, playheadY); + } else { + int scrubberDrawableWidth = (int) (scrubberDrawable.getIntrinsicWidth() * scrubberScale); + int scrubberDrawableHeight = (int) (scrubberDrawable.getIntrinsicHeight() * scrubberScale); + scrubberDrawable.setBounds( + playheadX - scrubberDrawableWidth / 2, + playheadY - scrubberDrawableHeight / 2, + playheadX + scrubberDrawableWidth / 2, + playheadY + scrubberDrawableHeight / 2); + scrubberDrawable.draw(canvas); + } + } + + private void drawThumbnail(Canvas canvas, int playheadRadius, int playheadX, int playheadY) { + + if (thumbnailUtils == null) return; + Bitmap b = thumbnailUtils.getThumbnail(getScrubberPosition()); + + if (b == null) return; + + //adapt thumbnail to desired UI size + double arFactor = (double) b.getWidth() / b.getHeight(); + b = Bitmap.createScaledBitmap(b, dpToPx((int)(targetThumbnailHeightInDp * arFactor)), dpToPx(targetThumbnailHeightInDp), false); + + int width = b.getWidth(); + int height = b.getHeight(); + int offset = (int)width / 2; + + int left = playheadX-offset; + + //handle full left, full right position cases + if (left < 0 ) left = 0; + if (left + width > progressBar.width() + playheadRadius) left = progressBar.width() + playheadRadius - width; + + canvas.drawBitmap(b, left, playheadY-playheadRadius*2-height, null); + } + + private int dpToPx(int dp){ + return (int) (dp * getContext().getResources().getDisplayMetrics().density); + } + + private void updateDrawableState() { + if (scrubberDrawable != null + && scrubberDrawable.isStateful() + && scrubberDrawable.setState(getDrawableState())) { + invalidate(); + } + } + + @RequiresApi(29) + private void setSystemGestureExclusionRectsV29(int width, int height) { + if (lastExclusionRectangle != null + && lastExclusionRectangle.width() == width + && lastExclusionRectangle.height() == height) { + // Allocating inside onLayout is considered a DrawAllocation lint error, so avoid if possible. + return; + } + lastExclusionRectangle = new Rect(/* left= */ 0, /* top= */ 0, width, height); + setSystemGestureExclusionRects(Collections.singletonList(lastExclusionRectangle)); + } + + private String getProgressText() { + return Util.getStringForTime(formatBuilder, formatter, position); + } + + private long getPositionIncrement() { + return keyTimeIncrement == C.TIME_UNSET + ? (duration == C.TIME_UNSET ? 0 : (duration / keyCountIncrement)) + : keyTimeIncrement; + } + + private boolean setDrawableLayoutDirection(Drawable drawable) { + return Util.SDK_INT >= 23 && setDrawableLayoutDirection(drawable, getLayoutDirection()); + } + + private static boolean setDrawableLayoutDirection(Drawable drawable, int layoutDirection) { + return Util.SDK_INT >= 23 && drawable.setLayoutDirection(layoutDirection); + } + + private static int dpToPx(float density, int dps) { + return (int) (dps * density + 0.5f); + } + + private static int pxToDp(float density, int px) { + return (int) (px / density); + } +} diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/ThumbnailProvider.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/ThumbnailProvider.java new file mode 100644 index 0000000000..129fc84b16 --- /dev/null +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/ThumbnailProvider.java @@ -0,0 +1,9 @@ +package com.google.android.exoplayer2.demo; + +import android.graphics.Bitmap; + +public interface ThumbnailProvider { + + public Bitmap getThumbnail(long position); + +} diff --git a/demos/main/src/main/res/layout/exo_styled_player_control_view.xml b/demos/main/src/main/res/layout/exo_styled_player_control_view.xml new file mode 100644 index 0000000000..2a6ae481da --- /dev/null +++ b/demos/main/src/main/res/layout/exo_styled_player_control_view.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +