diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 63f8b2594d..4fe20d018d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -202,6 +202,7 @@ * Upgrade Truth dependency from 0.44 to 1.0. * Upgrade to JUnit 4.13-rc-2. * UI + * Add `StyledPlayerView` and `StyledPlayerControlView`. * Remove `SimpleExoPlayerView` and `PlaybackControlView`. * Remove deperecated `exo_simple_player_view.xml` and `exo_playback_control_view.xml` from resource. diff --git a/constants.gradle b/constants.gradle index c95315df6d..0b3362a18a 100644 --- a/constants.gradle +++ b/constants.gradle @@ -34,6 +34,7 @@ project.ext { androidxCollectionVersion = '1.1.0' androidxMediaVersion = '1.0.1' androidxMultidexVersion = '2.0.0' + androidxRecyclerViewVersion = '1.1.0' androidxTestCoreVersion = '1.2.0' androidxTestJUnitVersion = '1.1.1' androidxTestRunnerVersion = '1.2.0' diff --git a/library/ui/build.gradle b/library/ui/build.gradle index 2184443a5d..5b24cfbc62 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -20,6 +20,7 @@ dependencies { api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion + implementation 'androidx.recyclerview:recyclerview:' + androidxRecyclerViewVersion implementation 'com.google.guava:guava:' + guavaVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java new file mode 100644 index 0000000000..5da2445f7e --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java @@ -0,0 +1,2210 @@ +/* + * Copyright 2019 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.ui; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; +import androidx.annotation.Nullable; +import androidx.core.content.res.ResourcesCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackPreparer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.ParametersBuilder; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.RepeatModeUtil; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Formatter; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A view for controlling {@link Player} instances. + * + *

A StyledPlayerControlView can be customized by setting attributes (or calling corresponding + * methods), overriding drawables, overriding the view's layout file, or by specifying a custom view + * layout file. + * + *

Attributes

+ * + * The following attributes can be set on a StyledPlayerControlView when used in a layout XML file: + * + * + * + *

Overriding drawables

+ * + * The drawables used by StyledPlayerControlView (with its default layout file) can be overridden by + * drawables with the same names defined in your application. The drawables that can be overridden + * are: + * + * + * + *

Overriding the layout file

+ * + * To customize the layout of StyledPlayerControlView throughout your app, or just for certain + * configurations, you can define {@code exo_styled_player_control_view.xml} layout files in your + * application {@code res/layout*} directories. But, in this case, you need to be careful since the + * default animation implementation expects certain relative positions between children. See also Specifying a custom layout file. + * + *

The layout files in your {@code res/layout*} will override the one provided by the ExoPlayer + * library, and will be inflated for use by StyledPlayerControlView. The view identifies and binds + * its children by looking for the following ids: + * + *

+ * + *

All child views are optional and so can be omitted if not required, however where defined they + * must be of the expected type. + * + *

Specifying a custom layout file

+ * + * Defining your own {@code exo_styled_player_control_view.xml} is useful to customize the layout of + * StyledPlayerControlView throughout your application. It's also possible to customize the layout + * for a single instance in a layout file. This is achieved by setting the {@code + * controller_layout_id} attribute on a StyledPlayerControlView. This will cause the specified + * layout to be inflated instead of {@code exo_styled_player_control_view.xml} for only the instance + * on which the attribute is set. + * + *

You need to be careful when you set the {@code controller_layout_id}, because the default + * animation implementation expects certain relative positions between children. + */ +public class StyledPlayerControlView extends FrameLayout { + + static { + ExoPlayerLibraryInfo.registerModule("goog.exo.ui"); + } + + /** Listener to be notified about changes of the visibility of the UI control. */ + public interface VisibilityListener { + + /** + * Called when the visibility changes. + * + * @param visibility The new visibility. Either {@link View#VISIBLE} or {@link View#GONE}. + */ + void onVisibilityChange(int visibility); + } + + /** Listener to be notified when progress has been updated. */ + public interface ProgressUpdateListener { + + /** + * Called when progress needs to be updated. + * + * @param position The current position. + * @param bufferedPosition The current buffered position. + */ + void onProgressUpdate(long position, long bufferedPosition); + } + + /** + * Listener to be invoked to inform the fullscreen mode is changed. Application should handle the + * fullscreen mode accordingly. + */ + public interface OnFullScreenModeChangedListener { + /** + * Called to indicate a fullscreen mode change. + * + * @param isFullScreen {@code true} if the video rendering surface should be fullscreen {@code + * false} otherwise. + */ + void onFullScreenModeChanged(boolean isFullScreen); + } + + /** The default show timeout, in milliseconds. */ + public static final int DEFAULT_SHOW_TIMEOUT_MS = 5_000; + /** The default repeat toggle modes. */ + public static final @RepeatModeUtil.RepeatToggleModes int DEFAULT_REPEAT_TOGGLE_MODES = + RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE; + /** The default minimum interval between time bar position updates. */ + public static final int DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS = 200; + /** The maximum number of windows that can be shown in a multi-window time bar. */ + public static final int MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR = 100; + /** The maximum interval between time bar position updates. */ + private static final int MAX_UPDATE_INTERVAL_MS = 1_000; + + private static final int SETTINGS_PLAYBACK_SPEED_POSITION = 0; + private static final int SETTINGS_AUDIO_TRACK_SELECTION_POSITION = 1; + private static final int UNDEFINED_POSITION = -1; + + private final ComponentListener componentListener; + private final CopyOnWriteArrayList visibilityListeners; + @Nullable private final View previousButton; + @Nullable private final View nextButton; + @Nullable private final View playPauseButton; + @Nullable private final View fastForwardButton; + @Nullable private final View rewindButton; + @Nullable private final TextView fastForwardButtonTextView; + @Nullable private final TextView rewindButtonTextView; + @Nullable private final ImageView repeatToggleButton; + @Nullable private final ImageView shuffleButton; + @Nullable private final View vrButton; + @Nullable private final TextView durationView; + @Nullable private final TextView positionView; + @Nullable private final TimeBar timeBar; + private final StringBuilder formatBuilder; + private final Formatter formatter; + private final Timeline.Period period; + private final Timeline.Window window; + private final Runnable updateProgressAction; + + private final Drawable repeatOffButtonDrawable; + private final Drawable repeatOneButtonDrawable; + private final Drawable repeatAllButtonDrawable; + private final String repeatOffButtonContentDescription; + private final String repeatOneButtonContentDescription; + private final String repeatAllButtonContentDescription; + private final Drawable shuffleOnButtonDrawable; + private final Drawable shuffleOffButtonDrawable; + private final float buttonAlphaEnabled; + private final float buttonAlphaDisabled; + private final String shuffleOnContentDescription; + private final String shuffleOffContentDescription; + private final Drawable fullScreenExitDrawable; + private final Drawable fullScreenEnterDrawable; + private final String fullScreenExitContentDescription; + private final String fullScreenEnterContentDescription; + + @Nullable private Player player; + private com.google.android.exoplayer2.ControlDispatcher controlDispatcher; + @Nullable private ProgressUpdateListener progressUpdateListener; + @Nullable private PlaybackPreparer playbackPreparer; + + @Nullable private OnFullScreenModeChangedListener onFullScreenModeChangedListener; + private boolean isFullScreen; + private boolean isAttachedToWindow; + private boolean showMultiWindowTimeBar; + private boolean multiWindowTimeBar; + private boolean scrubbing; + private int showTimeoutMs; + private int timeBarMinUpdateIntervalMs; + private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes; + private boolean showRewindButton; + private boolean showFastForwardButton; + private boolean showPreviousButton; + private boolean showNextButton; + private boolean showShuffleButton; + private boolean showSubtitleButton; + private long[] adGroupTimesMs; + private boolean[] playedAdGroups; + private long[] extraAdGroupTimesMs; + private boolean[] extraPlayedAdGroups; + private long currentWindowOffset; + private long rewindMs; + private long fastForwardMs; + + private StyledPlayerControlViewLayoutManager controlViewLayoutManager; + private Resources resources; + + // Relating to Settings List View + private int selectedMainSettingsPosition; + private RecyclerView settingsView; + private SettingsAdapter settingsAdapter; + private SubSettingsAdapter subSettingsAdapter; + private PopupWindow settingsWindow; + private List playbackSpeedTextList; + private List playbackSpeedMultBy100List; + private int customPlaybackSpeedIndex; + private int selectedPlaybackSpeedIndex; + private boolean needToHideBars; + private int settingsWindowMargin; + + @Nullable private DefaultTrackSelector trackSelector; + private TrackSelectionAdapter textTrackSelectionAdapter; + private TrackSelectionAdapter audioTrackSelectionAdapter; + // TODO(insun): Add setTrackNameProvider to use customized track name provider. + private TrackNameProvider trackNameProvider; + + // Relating to Bottom Bar Right View + @Nullable private View subtitleButton; + @Nullable private ImageView fullScreenButton; + @Nullable private View settingsButton; + + public StyledPlayerControlView(Context context) { + this(context, /* attrs= */ null); + } + + public StyledPlayerControlView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, /* defStyleAttr= */ 0); + } + + public StyledPlayerControlView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, attrs); + } + + @SuppressWarnings({ + "nullness:argument.type.incompatible", + "nullness:method.invocation.invalid", + "nullness:methodref.receiver.bound.invalid" + }) + public StyledPlayerControlView( + Context context, + @Nullable AttributeSet attrs, + int defStyleAttr, + @Nullable AttributeSet playbackAttrs) { + super(context, attrs, defStyleAttr); + int controllerLayoutId = R.layout.exo_styled_player_control_view; + rewindMs = DefaultControlDispatcher.DEFAULT_REWIND_MS; + fastForwardMs = DefaultControlDispatcher.DEFAULT_FAST_FORWARD_MS; + showTimeoutMs = DEFAULT_SHOW_TIMEOUT_MS; + repeatToggleModes = DEFAULT_REPEAT_TOGGLE_MODES; + timeBarMinUpdateIntervalMs = DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS; + showRewindButton = true; + showFastForwardButton = true; + showPreviousButton = true; + showNextButton = true; + showShuffleButton = false; + showSubtitleButton = false; + boolean disableAnimation = false; + + boolean showVrButton = false; + + if (playbackAttrs != null) { + TypedArray a = + context + .getTheme() + .obtainStyledAttributes(playbackAttrs, R.styleable.StyledPlayerControlView, 0, 0); + try { + rewindMs = a.getInt(R.styleable.StyledPlayerControlView_rewind_increment, (int) rewindMs); + fastForwardMs = + a.getInt( + R.styleable.StyledPlayerControlView_fastforward_increment, (int) fastForwardMs); + controllerLayoutId = + a.getResourceId( + R.styleable.StyledPlayerControlView_controller_layout_id, controllerLayoutId); + showTimeoutMs = a.getInt(R.styleable.StyledPlayerControlView_show_timeout, showTimeoutMs); + repeatToggleModes = getRepeatToggleModes(a, repeatToggleModes); + showRewindButton = + a.getBoolean(R.styleable.StyledPlayerControlView_show_rewind_button, showRewindButton); + showFastForwardButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_fastforward_button, showFastForwardButton); + showPreviousButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_previous_button, showPreviousButton); + showNextButton = + a.getBoolean(R.styleable.StyledPlayerControlView_show_next_button, showNextButton); + showShuffleButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_shuffle_button, showShuffleButton); + showSubtitleButton = + a.getBoolean( + R.styleable.StyledPlayerControlView_show_subtitle_button, showSubtitleButton); + showVrButton = + a.getBoolean(R.styleable.StyledPlayerControlView_show_vr_button, showVrButton); + setTimeBarMinUpdateInterval( + a.getInt( + R.styleable.StyledPlayerControlView_time_bar_min_update_interval, + timeBarMinUpdateIntervalMs)); + disableAnimation = + a.getBoolean(R.styleable.StyledPlayerControlView_disable_animation, disableAnimation); + } finally { + a.recycle(); + } + } + + controlViewLayoutManager = new StyledPlayerControlViewLayoutManager(); + controlViewLayoutManager.setDisableAnimation(disableAnimation); + visibilityListeners = new CopyOnWriteArrayList<>(); + period = new Timeline.Period(); + window = new Timeline.Window(); + formatBuilder = new StringBuilder(); + formatter = new Formatter(formatBuilder, Locale.getDefault()); + adGroupTimesMs = new long[0]; + playedAdGroups = new boolean[0]; + extraAdGroupTimesMs = new long[0]; + extraPlayedAdGroups = new boolean[0]; + componentListener = new ComponentListener(); + controlDispatcher = + new com.google.android.exoplayer2.DefaultControlDispatcher(fastForwardMs, rewindMs); + updateProgressAction = this::updateProgress; + + LayoutInflater.from(context).inflate(controllerLayoutId, /* root= */ this); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + + // Relating to Bottom Bar Left View + durationView = findViewById(R.id.exo_duration); + positionView = findViewById(R.id.exo_position); + + // Relating to Bottom Bar Right View + subtitleButton = findViewById(R.id.exo_subtitle); + if (subtitleButton != null) { + subtitleButton.setOnClickListener(componentListener); + subtitleButton.setVisibility(showSubtitleButton ? VISIBLE : GONE); + } + fullScreenButton = findViewById(R.id.exo_fullscreen); + if (fullScreenButton != null) { + fullScreenButton.setOnClickListener(fullScreenModeChangedListener); + } + settingsButton = findViewById(R.id.exo_settings); + if (settingsButton != null) { + settingsButton.setOnClickListener(componentListener); + } + + TimeBar customTimeBar = findViewById(R.id.exo_progress); + View timeBarPlaceholder = findViewById(R.id.exo_progress_placeholder); + if (customTimeBar != null) { + timeBar = customTimeBar; + } else if (timeBarPlaceholder != null) { + // Propagate attrs as timebarAttrs so that DefaultTimeBar's custom attributes are transferred, + // but standard attributes (e.g. background) are not. + DefaultTimeBar defaultTimeBar = new DefaultTimeBar(context, null, 0, playbackAttrs); + defaultTimeBar.setId(R.id.exo_progress); + defaultTimeBar.setLayoutParams(timeBarPlaceholder.getLayoutParams()); + ViewGroup parent = ((ViewGroup) timeBarPlaceholder.getParent()); + int timeBarIndex = parent.indexOfChild(timeBarPlaceholder); + parent.removeView(timeBarPlaceholder); + parent.addView(defaultTimeBar, timeBarIndex); + timeBar = defaultTimeBar; + } else { + timeBar = null; + } + + if (timeBar != null) { + timeBar.addListener(componentListener); + } + playPauseButton = findViewById(R.id.exo_play_pause); + if (playPauseButton != null) { + playPauseButton.setOnClickListener(componentListener); + } + previousButton = findViewById(R.id.exo_prev); + if (previousButton != null) { + previousButton.setOnClickListener(componentListener); + } + nextButton = findViewById(R.id.exo_next); + if (nextButton != null) { + nextButton.setOnClickListener(componentListener); + } + Typeface typeface = ResourcesCompat.getFont(context, R.font.roboto_medium_numbers); + View rewButton = findViewById(R.id.exo_rew); + rewindButtonTextView = rewButton == null ? findViewById(R.id.exo_rew_with_amount) : null; + if (rewindButtonTextView != null) { + rewindButtonTextView.setTypeface(typeface); + } + rewindButton = rewButton == null ? rewindButtonTextView : rewButton; + if (rewindButton != null) { + rewindButton.setOnClickListener(componentListener); + } + View ffwdButton = findViewById(R.id.exo_ffwd); + fastForwardButtonTextView = ffwdButton == null ? findViewById(R.id.exo_ffwd_with_amount) : null; + if (fastForwardButtonTextView != null) { + fastForwardButtonTextView.setTypeface(typeface); + } + fastForwardButton = ffwdButton == null ? fastForwardButtonTextView : ffwdButton; + if (fastForwardButton != null) { + fastForwardButton.setOnClickListener(componentListener); + } + repeatToggleButton = findViewById(R.id.exo_repeat_toggle); + if (repeatToggleButton != null) { + repeatToggleButton.setOnClickListener(componentListener); + } + shuffleButton = findViewById(R.id.exo_shuffle); + if (shuffleButton != null) { + shuffleButton.setOnClickListener(componentListener); + } + + resources = context.getResources(); + + buttonAlphaEnabled = + (float) resources.getInteger(R.integer.exo_media_button_opacity_percentage_enabled) / 100; + buttonAlphaDisabled = + (float) resources.getInteger(R.integer.exo_media_button_opacity_percentage_disabled) / 100; + + vrButton = findViewById(R.id.exo_vr); + if (vrButton != null) { + setShowVrButton(showVrButton); + } + + // Related to Settings List View + List settingsMainTextsList = + Arrays.asList(resources.getStringArray(R.array.exo_settings_main_texts)); + TypedArray settingsIconTypedArray = resources.obtainTypedArray(R.array.exo_settings_icon_ids); + playbackSpeedTextList = + new ArrayList<>(Arrays.asList(resources.getStringArray(R.array.exo_playback_speeds))); + String normalSpeed = resources.getString(R.string.exo_controls_playback_speed_normal); + selectedPlaybackSpeedIndex = playbackSpeedTextList.indexOf(normalSpeed); + + playbackSpeedMultBy100List = new ArrayList(); + int[] speeds = resources.getIntArray(R.array.exo_speed_multiplied_by_100); + for (int speed : speeds) { + playbackSpeedMultBy100List.add(speed); + } + customPlaybackSpeedIndex = UNDEFINED_POSITION; + settingsWindowMargin = resources.getDimensionPixelSize(R.dimen.exo_settings_offset); + + settingsAdapter = new SettingsAdapter(settingsMainTextsList, settingsIconTypedArray); + subSettingsAdapter = new SubSettingsAdapter(); + subSettingsAdapter.setCheckPosition(UNDEFINED_POSITION); + settingsView = + (RecyclerView) + LayoutInflater.from(context).inflate(R.layout.exo_styled_settings_list, null); + settingsView.setAdapter(settingsAdapter); + settingsView.setLayoutManager(new LinearLayoutManager(getContext())); + settingsWindow = + new PopupWindow(settingsView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, true); + settingsWindow.setOnDismissListener(componentListener); + needToHideBars = true; + + trackNameProvider = new DefaultTrackNameProvider(getResources()); + textTrackSelectionAdapter = new TextTrackSelectionAdapter(); + audioTrackSelectionAdapter = new AudioTrackSelectionAdapter(); + + fullScreenExitDrawable = resources.getDrawable(R.drawable.exo_styled_controls_fullscreen_exit); + fullScreenEnterDrawable = + resources.getDrawable(R.drawable.exo_styled_controls_fullscreen_enter); + repeatOffButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_repeat_off); + repeatOneButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_repeat_one); + repeatAllButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_repeat_all); + shuffleOnButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_shuffle_on); + shuffleOffButtonDrawable = resources.getDrawable(R.drawable.exo_styled_controls_shuffle_off); + fullScreenExitContentDescription = + resources.getString(R.string.exo_controls_fullscreen_exit_description); + fullScreenEnterContentDescription = + resources.getString(R.string.exo_controls_fullscreen_enter_description); + repeatOffButtonContentDescription = + resources.getString(R.string.exo_controls_repeat_off_description); + repeatOneButtonContentDescription = + resources.getString(R.string.exo_controls_repeat_one_description); + repeatAllButtonContentDescription = + resources.getString(R.string.exo_controls_repeat_all_description); + shuffleOnContentDescription = resources.getString(R.string.exo_controls_shuffle_on_description); + shuffleOffContentDescription = + resources.getString(R.string.exo_controls_shuffle_off_description); + + addOnLayoutChangeListener( + new OnLayoutChangeListener() { + @Override + public void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + int width = right - left; + int height = bottom - top; + int oldWidth = oldRight - oldLeft; + int oldHeight = oldBottom - oldTop; + + if ((width != oldWidth || height != oldHeight) && settingsWindow.isShowing()) { + updateSettingsWindowSize(); + + int xoff = getWidth() - settingsWindow.getWidth() - settingsWindowMargin; + int yoff = -settingsWindow.getHeight() - settingsWindowMargin; + + settingsWindow.update(v, xoff, yoff, -1, -1); + } + } + }); + } + + @SuppressWarnings("ResourceType") + private static @RepeatModeUtil.RepeatToggleModes int getRepeatToggleModes( + TypedArray a, @RepeatModeUtil.RepeatToggleModes int repeatToggleModes) { + return a.getInt(R.styleable.StyledPlayerControlView_repeat_toggle_modes, repeatToggleModes); + } + + /** + * Returns the {@link Player} currently being controlled by this view, or null if no player is + * set. + */ + @Nullable + public Player getPlayer() { + return player; + } + + /** + * Sets the {@link Player} to control. + * + * @param player The {@link Player} to control, or {@code null} to detach the current player. Only + * players which are accessed on the main thread are supported ({@code + * player.getApplicationLooper() == Looper.getMainLooper()}). + */ + public void setPlayer(@Nullable Player player) { + Assertions.checkState(Looper.myLooper() == Looper.getMainLooper()); + Assertions.checkArgument( + player == null || player.getApplicationLooper() == Looper.getMainLooper()); + if (this.player == player) { + return; + } + if (this.player != null) { + this.player.removeListener(componentListener); + } + this.player = player; + if (player != null) { + player.addListener(componentListener); + } + if (player != null && player.getTrackSelector() instanceof DefaultTrackSelector) { + this.trackSelector = (DefaultTrackSelector) player.getTrackSelector(); + } else { + this.trackSelector = null; + } + updateAll(); + updateSettingsPlaybackSpeedLists(); + } + + /** + * Sets whether the time bar should show all windows, as opposed to just the current one. If the + * timeline has a period with unknown duration or more than {@link + * #MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR} windows the time bar will fall back to showing a single + * window. + * + * @param showMultiWindowTimeBar Whether the time bar should show all windows. + */ + public void setShowMultiWindowTimeBar(boolean showMultiWindowTimeBar) { + this.showMultiWindowTimeBar = showMultiWindowTimeBar; + updateTimeline(); + } + + /** + * Sets the millisecond positions of extra ad markers relative to the start of the window (or + * timeline, if in multi-window mode) and whether each extra ad has been played or not. The + * markers are shown in addition to any ad markers for ads in the player's timeline. + * + * @param extraAdGroupTimesMs The millisecond timestamps of the extra ad markers to show, or + * {@code null} to show no extra ad markers. + * @param extraPlayedAdGroups Whether each ad has been played. Must be the same length as {@code + * extraAdGroupTimesMs}, or {@code null} if {@code extraAdGroupTimesMs} is {@code null}. + */ + public void setExtraAdGroupMarkers( + @Nullable long[] extraAdGroupTimesMs, @Nullable boolean[] extraPlayedAdGroups) { + if (extraAdGroupTimesMs == null) { + this.extraAdGroupTimesMs = new long[0]; + this.extraPlayedAdGroups = new boolean[0]; + } else { + extraPlayedAdGroups = checkNotNull(extraPlayedAdGroups); + Assertions.checkArgument(extraAdGroupTimesMs.length == extraPlayedAdGroups.length); + this.extraAdGroupTimesMs = extraAdGroupTimesMs; + this.extraPlayedAdGroups = extraPlayedAdGroups; + } + updateTimeline(); + } + + /** + * Adds a {@link VisibilityListener}. + * + * @param listener The listener to be notified about visibility changes. + */ + public void addVisibilityListener(VisibilityListener listener) { + visibilityListeners.add(listener); + } + + /** + * Removes a {@link VisibilityListener}. + * + * @param listener The listener to be removed. + */ + public void removeVisibilityListener(VisibilityListener listener) { + visibilityListeners.remove(listener); + } + + /** + * Sets the {@link ProgressUpdateListener}. + * + * @param listener The listener to be notified about when progress is updated. + */ + public void setProgressUpdateListener(@Nullable ProgressUpdateListener listener) { + this.progressUpdateListener = listener; + } + + /** + * Sets the {@link PlaybackPreparer}. + * + * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback + * preparer. + */ + public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { + this.playbackPreparer = playbackPreparer; + } + + /** + * Sets the {@link com.google.android.exoplayer2.ControlDispatcher}. + * + * @param controlDispatcher The {@link com.google.android.exoplayer2.ControlDispatcher}. + */ + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + if (this.controlDispatcher != controlDispatcher) { + this.controlDispatcher = controlDispatcher; + updateNavigation(); + } + } + + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + this.showRewindButton = showRewindButton; + updateNavigation(); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + this.showFastForwardButton = showFastForwardButton; + updateNavigation(); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + this.showPreviousButton = showPreviousButton; + updateNavigation(); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + this.showNextButton = showNextButton; + updateNavigation(); + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setRewindIncrementMs(int rewindMs) { + if (controlDispatcher instanceof DefaultControlDispatcher) { + ((DefaultControlDispatcher) controlDispatcher).setRewindIncrementMs(rewindMs); + updateNavigation(); + } + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setFastForwardIncrementMs(int fastForwardMs) { + if (controlDispatcher instanceof DefaultControlDispatcher) { + ((DefaultControlDispatcher) controlDispatcher).setFastForwardIncrementMs(fastForwardMs); + updateNavigation(); + } + } + + /** + * Returns the playback controls timeout. The playback controls are automatically hidden after + * this duration of time has elapsed without user input. + * + * @return The duration in milliseconds. A non-positive value indicates that the controls will + * remain visible indefinitely. + */ + public int getShowTimeoutMs() { + return showTimeoutMs; + } + + /** + * Sets the playback controls timeout. The playback controls are automatically hidden after this + * duration of time has elapsed without user input. + * + * @param showTimeoutMs The duration in milliseconds. A non-positive value will cause the controls + * to remain visible indefinitely. + */ + public void setShowTimeoutMs(int showTimeoutMs) { + this.showTimeoutMs = showTimeoutMs; + if (isFullyVisible()) { + controlViewLayoutManager.resetHideCallbacks(); + } + } + + /** + * Returns which repeat toggle modes are enabled. + * + * @return The currently enabled {@link RepeatModeUtil.RepeatToggleModes}. + */ + public @RepeatModeUtil.RepeatToggleModes int getRepeatToggleModes() { + return repeatToggleModes; + } + + /** + * Sets which repeat toggle modes are enabled. + * + * @param repeatToggleModes A set of {@link RepeatModeUtil.RepeatToggleModes}. + */ + public void setRepeatToggleModes(@RepeatModeUtil.RepeatToggleModes int repeatToggleModes) { + this.repeatToggleModes = repeatToggleModes; + if (player != null) { + @Player.RepeatMode int currentMode = player.getRepeatMode(); + if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE + && currentMode != Player.REPEAT_MODE_OFF) { + controlDispatcher.dispatchSetRepeatMode(player, Player.REPEAT_MODE_OFF); + } else if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE + && currentMode == Player.REPEAT_MODE_ALL) { + controlDispatcher.dispatchSetRepeatMode(player, Player.REPEAT_MODE_ONE); + } else if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL + && currentMode == Player.REPEAT_MODE_ONE) { + controlDispatcher.dispatchSetRepeatMode(player, Player.REPEAT_MODE_ALL); + } + } + updateRepeatModeButton(); + } + + /** Returns whether the shuffle button is shown. */ + public boolean getShowShuffleButton() { + return showShuffleButton; + } + + /** + * Sets whether the shuffle button is shown. + * + * @param showShuffleButton Whether the shuffle button is shown. + */ + public void setShowShuffleButton(boolean showShuffleButton) { + this.showShuffleButton = showShuffleButton; + updateShuffleButton(); + } + + /** Returns whether the subtitle button is shown. */ + public boolean getShowSubtitleButton() { + return showSubtitleButton; + } + + /** + * Sets whether the subtitle button is shown. + * + * @param showSubtitleButton Whether the subtitle button is shown. + */ + public void setShowSubtitleButton(boolean showSubtitleButton) { + this.showSubtitleButton = showSubtitleButton; + if (subtitleButton != null) { + subtitleButton.setVisibility(showSubtitleButton ? VISIBLE : GONE); + } + } + + /** Returns whether the VR button is shown. */ + public boolean getShowVrButton() { + return vrButton != null && vrButton.getVisibility() == VISIBLE; + } + + /** + * Sets whether the VR button is shown. + * + * @param showVrButton Whether the VR button is shown. + */ + public void setShowVrButton(boolean showVrButton) { + if (vrButton != null) { + updateButton(showVrButton, vrButton.hasOnClickListeners(), vrButton); + } + } + + /** + * Sets listener for the VR button. + * + * @param onClickListener Listener for the VR button, or null to clear the listener. + */ + public void setVrButtonListener(@Nullable OnClickListener onClickListener) { + if (vrButton != null) { + vrButton.setOnClickListener(onClickListener); + updateButton(getShowVrButton(), onClickListener != null, vrButton); + } + } + + /** + * Sets the minimum interval between time bar position updates. + * + *

Note that smaller intervals, e.g. 33ms, will result in a smooth movement but will use more + * CPU resources while the time bar is visible, whereas larger intervals, e.g. 200ms, will result + * in a step-wise update with less CPU usage. + * + * @param minUpdateIntervalMs The minimum interval between time bar position updates, in + * milliseconds. + */ + public void setTimeBarMinUpdateInterval(int minUpdateIntervalMs) { + // Do not accept values below 16ms (60fps) and larger than the maximum update interval. + timeBarMinUpdateIntervalMs = + Util.constrainValue(minUpdateIntervalMs, 16, MAX_UPDATE_INTERVAL_MS); + } + + /** + * Sets a listener to be called when the fullscreen mode should be changed. A non-null listener + * needs to be set in order to display the fullscreen button. + * + * @param listener The listener to be called. A value of null removes any existing + * listener and hides the fullscreen button. + */ + public void setOnFullScreenModeChangedListener( + @Nullable OnFullScreenModeChangedListener listener) { + if (fullScreenButton == null) { + return; + } + + onFullScreenModeChangedListener = listener; + if (onFullScreenModeChangedListener == null) { + fullScreenButton.setVisibility(GONE); + } else { + fullScreenButton.setVisibility(VISIBLE); + } + } + + /** + * Shows the playback controls. If {@link #getShowTimeoutMs()} is positive then the controls will + * be automatically hidden after this duration of time has elapsed without user input. + */ + public void show() { + controlViewLayoutManager.show(); + } + + /** Hides the controller. */ + public void hide() { + controlViewLayoutManager.hide(); + } + + /** Returns whether the controller is fully visible, which means all UI controls are visible. */ + public boolean isFullyVisible() { + return controlViewLayoutManager.isFullyVisible(); + } + + /** Returns whether the controller is currently visible. */ + public boolean isVisible() { + return getVisibility() == VISIBLE; + } + + /* package */ void notifyOnVisibilityChange() { + for (VisibilityListener visibilityListener : visibilityListeners) { + visibilityListener.onVisibilityChange(getVisibility()); + } + } + + /* package */ void updateAll() { + updatePlayPauseButton(); + updateNavigation(); + updateRepeatModeButton(); + updateShuffleButton(); + updateTrackLists(); + updateTimeline(); + } + + private void updatePlayPauseButton() { + if (!isVisible() || !isAttachedToWindow) { + return; + } + if (playPauseButton != null) { + if (player != null && player.isPlaying()) { + ((ImageView) playPauseButton) + .setImageDrawable(resources.getDrawable(R.drawable.exo_styled_controls_pause)); + playPauseButton.setContentDescription( + resources.getString(R.string.exo_controls_pause_description)); + } else { + ((ImageView) playPauseButton) + .setImageDrawable(resources.getDrawable(R.drawable.exo_styled_controls_play)); + playPauseButton.setContentDescription( + resources.getString(R.string.exo_controls_play_description)); + } + } + } + + private void updateNavigation() { + if (!isVisible() || !isAttachedToWindow) { + return; + } + + @Nullable Player player = this.player; + boolean enableSeeking = false; + boolean enablePrevious = false; + boolean enableRewind = false; + boolean enableFastForward = false; + boolean enableNext = false; + if (player != null) { + Timeline timeline = player.getCurrentTimeline(); + if (!timeline.isEmpty() && !player.isPlayingAd()) { + timeline.getWindow(player.getCurrentWindowIndex(), window); + boolean isSeekable = window.isSeekable; + enableSeeking = isSeekable; + enablePrevious = isSeekable || !window.isDynamic || player.hasPrevious(); + enableRewind = isSeekable && controlDispatcher.isRewindEnabled(); + enableFastForward = isSeekable && controlDispatcher.isFastForwardEnabled(); + enableNext = window.isDynamic || player.hasNext(); + } + } + + if (enableRewind) { + updateRewindButton(); + } + if (enableFastForward) { + updateFastForwardButton(); + } + + updateButton(showPreviousButton, enablePrevious, previousButton); + updateButton(showRewindButton, enableRewind, rewindButton); + updateButton(showFastForwardButton, enableFastForward, fastForwardButton); + updateButton(showNextButton, enableNext, nextButton); + if (timeBar != null) { + timeBar.setEnabled(enableSeeking); + } + } + + private void updateRewindButton() { + if (controlDispatcher instanceof DefaultControlDispatcher) { + rewindMs = ((DefaultControlDispatcher) controlDispatcher).getRewindIncrementMs(); + } + long rewindSec = rewindMs / 1_000; + if (rewindButtonTextView != null) { + rewindButtonTextView.setText(String.valueOf(rewindSec)); + } + if (rewindButton != null) { + rewindButton.setContentDescription( + resources.getString(R.string.exo_controls_rewind_desc_holder, rewindSec)); + } + } + + private void updateFastForwardButton() { + if (controlDispatcher instanceof DefaultControlDispatcher) { + fastForwardMs = ((DefaultControlDispatcher) controlDispatcher).getFastForwardIncrementMs(); + } + long fastForwardSec = fastForwardMs / 1_000; + if (fastForwardButtonTextView != null) { + fastForwardButtonTextView.setText(String.valueOf(fastForwardSec)); + } + if (fastForwardButton != null) { + fastForwardButton.setContentDescription( + resources.getString(R.string.exo_controls_ffwd_desc_holder, fastForwardSec)); + } + } + + private void updateRepeatModeButton() { + if (!isVisible() || !isAttachedToWindow || repeatToggleButton == null) { + return; + } + + if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE) { + updateButton(/* visible= */ false, /* enabled= */ false, repeatToggleButton); + return; + } + + @Nullable Player player = this.player; + if (player == null) { + updateButton(/* visible= */ true, /* enabled= */ false, repeatToggleButton); + repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); + repeatToggleButton.setContentDescription(repeatOffButtonContentDescription); + return; + } + + updateButton(/* visible= */ true, /* enabled= */ true, repeatToggleButton); + switch (player.getRepeatMode()) { + case Player.REPEAT_MODE_OFF: + repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); + repeatToggleButton.setContentDescription(repeatOffButtonContentDescription); + break; + case Player.REPEAT_MODE_ONE: + repeatToggleButton.setImageDrawable(repeatOneButtonDrawable); + repeatToggleButton.setContentDescription(repeatOneButtonContentDescription); + break; + case Player.REPEAT_MODE_ALL: + repeatToggleButton.setImageDrawable(repeatAllButtonDrawable); + repeatToggleButton.setContentDescription(repeatAllButtonContentDescription); + break; + default: + // Never happens. + } + } + + private void updateShuffleButton() { + if (!isVisible() || !isAttachedToWindow || shuffleButton == null) { + return; + } + + @Nullable Player player = this.player; + if (!showShuffleButton) { + updateButton(/* visible= */ false, /* enabled= */ false, shuffleButton); + } else if (player == null) { + updateButton(/* visible= */ true, /* enabled= */ false, shuffleButton); + shuffleButton.setImageDrawable(shuffleOffButtonDrawable); + shuffleButton.setContentDescription(shuffleOffContentDescription); + } else { + updateButton(/* visible= */ true, /* enabled= */ true, shuffleButton); + shuffleButton.setImageDrawable( + player.getShuffleModeEnabled() ? shuffleOnButtonDrawable : shuffleOffButtonDrawable); + shuffleButton.setContentDescription( + player.getShuffleModeEnabled() + ? shuffleOnContentDescription + : shuffleOffContentDescription); + } + } + + private void updateTrackLists() { + initTrackSelectionAdapter(); + updateButton(showSubtitleButton, textTrackSelectionAdapter.getItemCount() > 0, subtitleButton); + } + + private void initTrackSelectionAdapter() { + textTrackSelectionAdapter.clear(); + audioTrackSelectionAdapter.clear(); + if (player == null || trackSelector == null) { + return; + } + DefaultTrackSelector trackSelector = this.trackSelector; + @Nullable MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo == null) { + return; + } + List textTracks = new ArrayList<>(); + List audioTracks = new ArrayList<>(); + List textRendererIndices = new ArrayList<>(); + List audioRendererIndices = new ArrayList<>(); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + if (mappedTrackInfo.getRendererType(rendererIndex) == C.TRACK_TYPE_TEXT + && showSubtitleButton) { + // Get TrackSelection at the corresponding renderer index. + gatherTrackInfosForAdapter(mappedTrackInfo, rendererIndex, textTracks); + textRendererIndices.add(rendererIndex); + } else if (mappedTrackInfo.getRendererType(rendererIndex) == C.TRACK_TYPE_AUDIO) { + gatherTrackInfosForAdapter(mappedTrackInfo, rendererIndex, audioTracks); + audioRendererIndices.add(rendererIndex); + } + } + textTrackSelectionAdapter.init(textRendererIndices, textTracks, mappedTrackInfo); + audioTrackSelectionAdapter.init(audioRendererIndices, audioTracks, mappedTrackInfo); + } + + private void gatherTrackInfosForAdapter( + MappedTrackInfo mappedTrackInfo, int rendererIndex, List tracks) { + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex); + + TrackSelectionArray trackSelections = checkNotNull(player).getCurrentTrackSelections(); + @Nullable TrackSelection trackSelection = trackSelections.get(rendererIndex); + + for (int groupIndex = 0; groupIndex < trackGroupArray.length; groupIndex++) { + TrackGroup trackGroup = trackGroupArray.get(groupIndex); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + Format format = trackGroup.getFormat(trackIndex); + if (mappedTrackInfo.getTrackSupport(rendererIndex, groupIndex, trackIndex) + == RendererCapabilities.FORMAT_HANDLED) { + boolean trackIsSelected = + trackSelection != null && trackSelection.indexOf(format) != C.INDEX_UNSET; + tracks.add( + new TrackInfo( + rendererIndex, + groupIndex, + trackIndex, + trackNameProvider.getTrackName(format), + trackIsSelected)); + } + } + } + } + + private void updateTimeline() { + @Nullable Player player = this.player; + if (player == null) { + return; + } + multiWindowTimeBar = + showMultiWindowTimeBar && canShowMultiWindowTimeBar(player.getCurrentTimeline(), window); + currentWindowOffset = 0; + long durationUs = 0; + int adGroupCount = 0; + Timeline timeline = player.getCurrentTimeline(); + if (!timeline.isEmpty()) { + int currentWindowIndex = player.getCurrentWindowIndex(); + int firstWindowIndex = multiWindowTimeBar ? 0 : currentWindowIndex; + int lastWindowIndex = multiWindowTimeBar ? timeline.getWindowCount() - 1 : currentWindowIndex; + for (int i = firstWindowIndex; i <= lastWindowIndex; i++) { + if (i == currentWindowIndex) { + currentWindowOffset = C.usToMs(durationUs); + } + timeline.getWindow(i, window); + if (window.durationUs == C.TIME_UNSET) { + Assertions.checkState(!multiWindowTimeBar); + break; + } + for (int j = window.firstPeriodIndex; j <= window.lastPeriodIndex; j++) { + timeline.getPeriod(j, period); + int periodAdGroupCount = period.getAdGroupCount(); + for (int adGroupIndex = 0; adGroupIndex < periodAdGroupCount; adGroupIndex++) { + long adGroupTimeInPeriodUs = period.getAdGroupTimeUs(adGroupIndex); + if (adGroupTimeInPeriodUs == C.TIME_END_OF_SOURCE) { + if (period.durationUs == C.TIME_UNSET) { + // Don't show ad markers for postrolls in periods with unknown duration. + continue; + } + adGroupTimeInPeriodUs = period.durationUs; + } + long adGroupTimeInWindowUs = adGroupTimeInPeriodUs + period.getPositionInWindowUs(); + if (adGroupTimeInWindowUs >= 0) { + if (adGroupCount == adGroupTimesMs.length) { + int newLength = adGroupTimesMs.length == 0 ? 1 : adGroupTimesMs.length * 2; + adGroupTimesMs = Arrays.copyOf(adGroupTimesMs, newLength); + playedAdGroups = Arrays.copyOf(playedAdGroups, newLength); + } + adGroupTimesMs[adGroupCount] = C.usToMs(durationUs + adGroupTimeInWindowUs); + playedAdGroups[adGroupCount] = period.hasPlayedAdGroup(adGroupIndex); + adGroupCount++; + } + } + } + durationUs += window.durationUs; + } + } + long durationMs = C.usToMs(durationUs); + if (durationView != null) { + durationView.setText(Util.getStringForTime(formatBuilder, formatter, durationMs)); + } + if (timeBar != null) { + timeBar.setDuration(durationMs); + int extraAdGroupCount = extraAdGroupTimesMs.length; + int totalAdGroupCount = adGroupCount + extraAdGroupCount; + if (totalAdGroupCount > adGroupTimesMs.length) { + adGroupTimesMs = Arrays.copyOf(adGroupTimesMs, totalAdGroupCount); + playedAdGroups = Arrays.copyOf(playedAdGroups, totalAdGroupCount); + } + System.arraycopy(extraAdGroupTimesMs, 0, adGroupTimesMs, adGroupCount, extraAdGroupCount); + System.arraycopy(extraPlayedAdGroups, 0, playedAdGroups, adGroupCount, extraAdGroupCount); + timeBar.setAdGroupTimesMs(adGroupTimesMs, playedAdGroups, totalAdGroupCount); + } + updateProgress(); + } + + private void updateProgress() { + if (!isVisible() || !isAttachedToWindow) { + return; + } + @Nullable Player player = this.player; + long position = 0; + long bufferedPosition = 0; + if (player != null) { + position = currentWindowOffset + player.getContentPosition(); + bufferedPosition = currentWindowOffset + player.getContentBufferedPosition(); + } + if (positionView != null && !scrubbing) { + positionView.setText(Util.getStringForTime(formatBuilder, formatter, position)); + } + if (timeBar != null) { + timeBar.setPosition(position); + timeBar.setBufferedPosition(bufferedPosition); + } + if (progressUpdateListener != null) { + progressUpdateListener.onProgressUpdate(position, bufferedPosition); + } + + // Cancel any pending updates and schedule a new one if necessary. + removeCallbacks(updateProgressAction); + int playbackState = player == null ? Player.STATE_IDLE : player.getPlaybackState(); + if (player != null && player.isPlaying()) { + long mediaTimeDelayMs = + timeBar != null ? timeBar.getPreferredUpdateDelay() : MAX_UPDATE_INTERVAL_MS; + + // Limit delay to the start of the next full second to ensure position display is smooth. + long mediaTimeUntilNextFullSecondMs = 1000 - position % 1000; + mediaTimeDelayMs = Math.min(mediaTimeDelayMs, mediaTimeUntilNextFullSecondMs); + + // Calculate the delay until the next update in real time, taking playbackSpeed into account. + float playbackSpeed = player.getPlaybackSpeed(); + long delayMs = + playbackSpeed > 0 ? (long) (mediaTimeDelayMs / playbackSpeed) : MAX_UPDATE_INTERVAL_MS; + + // Constrain the delay to avoid too frequent / infrequent updates. + delayMs = Util.constrainValue(delayMs, timeBarMinUpdateIntervalMs, MAX_UPDATE_INTERVAL_MS); + postDelayed(updateProgressAction, delayMs); + } else if (playbackState != Player.STATE_ENDED && playbackState != Player.STATE_IDLE) { + postDelayed(updateProgressAction, MAX_UPDATE_INTERVAL_MS); + } + } + + private void updateSettingsPlaybackSpeedLists() { + if (player == null) { + return; + } + float speed = player.getPlaybackSpeed(); + int currentSpeedMultBy100 = Math.round(speed * 100); + int indexForCurrentSpeed = playbackSpeedMultBy100List.indexOf(currentSpeedMultBy100); + if (indexForCurrentSpeed == UNDEFINED_POSITION) { + if (customPlaybackSpeedIndex != UNDEFINED_POSITION) { + playbackSpeedMultBy100List.remove(customPlaybackSpeedIndex); + playbackSpeedTextList.remove(customPlaybackSpeedIndex); + customPlaybackSpeedIndex = UNDEFINED_POSITION; + } + indexForCurrentSpeed = + -Collections.binarySearch(playbackSpeedMultBy100List, currentSpeedMultBy100) - 1; + String customSpeedText = + resources.getString(R.string.exo_controls_custom_playback_speed, speed); + playbackSpeedMultBy100List.add(indexForCurrentSpeed, currentSpeedMultBy100); + playbackSpeedTextList.add(indexForCurrentSpeed, customSpeedText); + customPlaybackSpeedIndex = indexForCurrentSpeed; + } + + selectedPlaybackSpeedIndex = indexForCurrentSpeed; + settingsAdapter.updateSubTexts( + SETTINGS_PLAYBACK_SPEED_POSITION, playbackSpeedTextList.get(indexForCurrentSpeed)); + } + + private void updateSettingsWindowSize() { + settingsView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); + + int maxWidth = getWidth() - settingsWindowMargin * 2; + int itemWidth = settingsView.getMeasuredWidth(); + int width = Math.min(itemWidth, maxWidth); + settingsWindow.setWidth(width); + + int maxHeight = getHeight() - settingsWindowMargin * 2; + int totalHeight = settingsView.getMeasuredHeight(); + int height = Math.min(maxHeight, totalHeight); + settingsWindow.setHeight(height); + } + + private void displaySettingsWindow(RecyclerView.Adapter adapter) { + settingsView.setAdapter(adapter); + + updateSettingsWindowSize(); + + needToHideBars = false; + settingsWindow.dismiss(); + needToHideBars = true; + + int xoff = getWidth() - settingsWindow.getWidth() - settingsWindowMargin; + int yoff = -settingsWindow.getHeight() - settingsWindowMargin; + + settingsWindow.showAsDropDown(this, xoff, yoff); + } + + private void setPlaybackSpeed(float speed) { + if (player == null) { + return; + } + player.setPlaybackSpeed(speed); + } + + /* package */ void requestPlayPauseFocus() { + if (playPauseButton != null) { + playPauseButton.requestFocus(); + } + } + + private void updateButton(boolean visible, boolean enabled, @Nullable View view) { + if (view == null) { + return; + } + view.setEnabled(enabled); + view.setAlpha(enabled ? buttonAlphaEnabled : buttonAlphaDisabled); + view.setVisibility(visible ? VISIBLE : GONE); + } + + private void seekToTimeBarPosition(Player player, long positionMs) { + int windowIndex; + Timeline timeline = player.getCurrentTimeline(); + if (multiWindowTimeBar && !timeline.isEmpty()) { + int windowCount = timeline.getWindowCount(); + windowIndex = 0; + while (true) { + long windowDurationMs = timeline.getWindow(windowIndex, window).getDurationMs(); + if (positionMs < windowDurationMs) { + break; + } else if (windowIndex == windowCount - 1) { + // Seeking past the end of the last window should seek to the end of the timeline. + positionMs = windowDurationMs; + break; + } + positionMs -= windowDurationMs; + windowIndex++; + } + } else { + windowIndex = player.getCurrentWindowIndex(); + } + boolean dispatched = seekTo(player, windowIndex, positionMs); + if (!dispatched) { + // The seek wasn't dispatched then the progress bar scrubber will be in the wrong position. + // Trigger a progress update to snap it back. + updateProgress(); + } + } + + private boolean seekTo(Player player, int windowIndex, long positionMs) { + return controlDispatcher.dispatchSeekTo(player, windowIndex, positionMs); + } + + private final OnClickListener fullScreenModeChangedListener = + new OnClickListener() { + + @Override + public void onClick(View v) { + if (onFullScreenModeChangedListener == null || fullScreenButton == null) { + return; + } + + isFullScreen = !isFullScreen; + if (isFullScreen) { + fullScreenButton.setImageDrawable(fullScreenExitDrawable); + fullScreenButton.setContentDescription(fullScreenExitContentDescription); + } else { + fullScreenButton.setImageDrawable(fullScreenEnterDrawable); + fullScreenButton.setContentDescription(fullScreenEnterContentDescription); + } + + if (onFullScreenModeChangedListener != null) { + onFullScreenModeChangedListener.onFullScreenModeChanged(isFullScreen); + } + } + }; + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + controlViewLayoutManager.onViewAttached(this); + isAttachedToWindow = true; + if (isFullyVisible()) { + controlViewLayoutManager.resetHideCallbacks(); + } + updateAll(); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + controlViewLayoutManager.onViewDetached(this); + isAttachedToWindow = false; + removeCallbacks(updateProgressAction); + controlViewLayoutManager.removeHideCallbacks(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + return dispatchMediaKeyEvent(event) || super.dispatchKeyEvent(event); + } + + /** + * Called to process media key events. Any {@link KeyEvent} can be passed but only media key + * events will be handled. + * + * @param event A key event. + * @return Whether the key event was handled. + */ + public boolean dispatchMediaKeyEvent(KeyEvent event) { + int keyCode = event.getKeyCode(); + @Nullable Player player = this.player; + if (player == null || !isHandledMediaKey(keyCode)) { + return false; + } + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) { + controlDispatcher.dispatchFastForward(player); + } else if (keyCode == KeyEvent.KEYCODE_MEDIA_REWIND) { + controlDispatcher.dispatchRewind(player); + } else if (event.getRepeatCount() == 0) { + switch (keyCode) { + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + controlDispatcher.dispatchSetPlayWhenReady(player, !player.getPlayWhenReady()); + break; + case KeyEvent.KEYCODE_MEDIA_PLAY: + controlDispatcher.dispatchSetPlayWhenReady(player, true); + break; + case KeyEvent.KEYCODE_MEDIA_PAUSE: + controlDispatcher.dispatchSetPlayWhenReady(player, false); + break; + case KeyEvent.KEYCODE_MEDIA_NEXT: + controlDispatcher.dispatchNext(player); + break; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + controlDispatcher.dispatchPrevious(player); + break; + default: + break; + } + } + } + return true; + } + + private boolean shouldShowPauseButton() { + return player != null + && player.getPlaybackState() != Player.STATE_ENDED + && player.getPlaybackState() != Player.STATE_IDLE + && player.getPlayWhenReady(); + } + + @SuppressLint("InlinedApi") + private static boolean isHandledMediaKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD + || keyCode == KeyEvent.KEYCODE_MEDIA_REWIND + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY + || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT + || keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS; + } + + /** + * Returns whether the specified {@code timeline} can be shown on a multi-window time bar. + * + * @param timeline The {@link Timeline} to check. + * @param window A scratch {@link Timeline.Window} instance. + * @return Whether the specified timeline can be shown on a multi-window time bar. + */ + private static boolean canShowMultiWindowTimeBar(Timeline timeline, Timeline.Window window) { + if (timeline.getWindowCount() > MAX_WINDOWS_FOR_MULTI_WINDOW_TIME_BAR) { + return false; + } + int windowCount = timeline.getWindowCount(); + for (int i = 0; i < windowCount; i++) { + if (timeline.getWindow(i, window).durationUs == C.TIME_UNSET) { + return false; + } + } + return true; + } + + private final class ComponentListener + implements Player.EventListener, + TimeBar.OnScrubListener, + OnClickListener, + PopupWindow.OnDismissListener { + + @Override + public void onScrubStart(TimeBar timeBar, long position) { + scrubbing = true; + if (positionView != null) { + positionView.setText(Util.getStringForTime(formatBuilder, formatter, position)); + } + controlViewLayoutManager.removeHideCallbacks(); + } + + @Override + public void onScrubMove(TimeBar timeBar, long position) { + if (positionView != null) { + positionView.setText(Util.getStringForTime(formatBuilder, formatter, position)); + } + } + + @Override + public void onScrubStop(TimeBar timeBar, long position, boolean canceled) { + scrubbing = false; + if (!canceled && player != null) { + seekToTimeBarPosition(player, position); + } + controlViewLayoutManager.resetHideCallbacks(); + } + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + updatePlayPauseButton(); + updateProgress(); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int state) { + updatePlayPauseButton(); + updateProgress(); + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + updateProgress(); + } + + @Override + public void onRepeatModeChanged(int repeatMode) { + updateRepeatModeButton(); + updateNavigation(); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + updateShuffleButton(); + updateNavigation(); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + updateNavigation(); + updateTimeline(); + } + + @Override + public void onPlaybackSpeedChanged(float playbackSpeed) { + updateSettingsPlaybackSpeedLists(); + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + updateTrackLists(); + } + + @Override + public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + updateNavigation(); + updateTimeline(); + } + + @Override + public void onDismiss() { + if (needToHideBars) { + controlViewLayoutManager.resetHideCallbacks(); + } + } + + @Override + public void onClick(View view) { + @Nullable Player player = StyledPlayerControlView.this.player; + if (player == null) { + return; + } + controlViewLayoutManager.resetHideCallbacks(); + if (nextButton == view) { + controlDispatcher.dispatchNext(player); + } else if (previousButton == view) { + controlDispatcher.dispatchPrevious(player); + } else if (fastForwardButton == view) { + controlDispatcher.dispatchFastForward(player); + } else if (rewindButton == view) { + controlDispatcher.dispatchRewind(player); + } else if (playPauseButton == view) { + if (player.getPlaybackState() == Player.STATE_IDLE) { + if (playbackPreparer != null) { + playbackPreparer.preparePlayback(); + } + } else if (player.getPlaybackState() == Player.STATE_ENDED) { + seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); + } + controlDispatcher.dispatchSetPlayWhenReady(player, !player.isPlaying()); + } else if (repeatToggleButton == view) { + controlDispatcher.dispatchSetRepeatMode( + player, RepeatModeUtil.getNextRepeatMode(player.getRepeatMode(), repeatToggleModes)); + } else if (shuffleButton == view) { + controlDispatcher.dispatchSetShuffleModeEnabled(player, !player.getShuffleModeEnabled()); + } else if (settingsButton == view) { + controlViewLayoutManager.removeHideCallbacks(); + displaySettingsWindow(settingsAdapter); + } else if (subtitleButton == view) { + controlViewLayoutManager.removeHideCallbacks(); + displaySettingsWindow(textTrackSelectionAdapter); + } + } + } + + private class SettingsAdapter extends RecyclerView.Adapter { + private List mainTexts; + @Nullable private List subTexts; + @Nullable private TypedArray iconIds; + + public SettingsAdapter(List mainTexts, @Nullable TypedArray iconIds) { + this.mainTexts = mainTexts; + this.subTexts = Arrays.asList(new String[mainTexts.size()]); + this.iconIds = iconIds; + } + + @Override + public SettingsViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + View v = + LayoutInflater.from(getContext()).inflate(R.layout.exo_styled_settings_list_item, null); + return new SettingsViewHolder(v); + } + + @Override + public void onBindViewHolder(SettingsViewHolder holder, int position) { + holder.mainTextView.setText(mainTexts.get(position)); + + if (subTexts == null || subTexts.get(position) == null) { + holder.subTextView.setVisibility(GONE); + } else { + holder.subTextView.setText(subTexts.get(position)); + } + + if (iconIds == null || iconIds.getDrawable(position) == null) { + holder.iconView.setVisibility(GONE); + } else { + holder.iconView.setImageDrawable(iconIds.getDrawable(position)); + } + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemCount() { + return mainTexts.size(); + } + + public void updateSubTexts(int position, String subText) { + if (this.subTexts != null) { + this.subTexts.set(position, subText); + } + } + + private class SettingsViewHolder extends RecyclerView.ViewHolder { + TextView mainTextView; + TextView subTextView; + ImageView iconView; + + SettingsViewHolder(View itemView) { + super(itemView); + + mainTextView = itemView.findViewById(R.id.exo_main_text); + subTextView = itemView.findViewById(R.id.exo_sub_text); + iconView = itemView.findViewById(R.id.exo_icon); + + itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + int position = SettingsViewHolder.this.getAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return; + } + + if (position == SETTINGS_PLAYBACK_SPEED_POSITION) { + subSettingsAdapter.setTexts(playbackSpeedTextList); + subSettingsAdapter.setCheckPosition(selectedPlaybackSpeedIndex); + selectedMainSettingsPosition = SETTINGS_PLAYBACK_SPEED_POSITION; + displaySettingsWindow(subSettingsAdapter); + } else if (position == SETTINGS_AUDIO_TRACK_SELECTION_POSITION) { + selectedMainSettingsPosition = SETTINGS_AUDIO_TRACK_SELECTION_POSITION; + displaySettingsWindow(audioTrackSelectionAdapter); + } else { + settingsWindow.dismiss(); + } + } + }); + } + } + } + + private class SubSettingsAdapter + extends RecyclerView.Adapter { + @Nullable private List texts; + private int checkPosition; + + @Override + public SubSettingsViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View v = + LayoutInflater.from(getContext()) + .inflate(R.layout.exo_styled_sub_settings_list_item, null); + return new SubSettingsViewHolder(v); + } + + @Override + public void onBindViewHolder(SubSettingsViewHolder holder, int position) { + if (texts != null) { + holder.textView.setText(texts.get(position)); + } + holder.checkView.setVisibility(position == checkPosition ? VISIBLE : INVISIBLE); + } + + @Override + public int getItemCount() { + return texts != null ? texts.size() : 0; + } + + public void setTexts(@Nullable List texts) { + this.texts = texts; + } + + public void setCheckPosition(int checkPosition) { + this.checkPosition = checkPosition; + } + + private class SubSettingsViewHolder extends RecyclerView.ViewHolder { + TextView textView; + View checkView; + + SubSettingsViewHolder(View itemView) { + super(itemView); + + textView = itemView.findViewById(R.id.exo_text); + checkView = itemView.findViewById(R.id.exo_check); + + itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + int position = SubSettingsViewHolder.this.getAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return; + } + + if (selectedMainSettingsPosition == SETTINGS_PLAYBACK_SPEED_POSITION) { + if (position != selectedPlaybackSpeedIndex) { + float speed = playbackSpeedMultBy100List.get(position) / 100.0f; + setPlaybackSpeed(speed); + } + } + settingsWindow.dismiss(); + } + }); + } + } + } + + private static final class TrackInfo { + public final int rendererIndex; + public final int groupIndex; + public final int trackIndex; + public final String trackName; + public final boolean selected; + + public TrackInfo( + int rendererIndex, int groupIndex, int trackIndex, String trackName, boolean selected) { + this.rendererIndex = rendererIndex; + this.groupIndex = groupIndex; + this.trackIndex = trackIndex; + this.trackName = trackName; + this.selected = selected; + } + } + + private final class TextTrackSelectionAdapter extends TrackSelectionAdapter { + @Override + public void init( + List rendererIndices, + List trackInfos, + MappedTrackInfo mappedTrackInfo) { + this.rendererIndices = rendererIndices; + this.tracks = trackInfos; + this.mappedTrackInfo = mappedTrackInfo; + } + + @Override + public void onBindViewHolderAtZeroPosition(TrackSelectionViewHolder holder) { + // CC options include "Off" at the first position, which disables text rendering. + holder.textView.setText(R.string.exo_track_selection_none); + boolean isTrackSelectionOff = true; + for (int i = 0; i < tracks.size(); i++) { + if (tracks.get(i).selected) { + isTrackSelectionOff = false; + break; + } + } + holder.checkView.setVisibility(isTrackSelectionOff ? VISIBLE : INVISIBLE); + holder.itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + if (trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + parametersBuilder = + parametersBuilder + .clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, true); + } + checkNotNull(trackSelector).setParameters(parametersBuilder); + settingsWindow.dismiss(); + } + } + }); + } + + @Override + public void updateSettingsSubtext(String subtext) { + // Do nothing. Text track selection exists outside of Settings menu. + } + } + + private final class AudioTrackSelectionAdapter extends TrackSelectionAdapter { + + @Override + public void onBindViewHolderAtZeroPosition(TrackSelectionViewHolder holder) { + // Audio track selection option includes "Auto" at the top. + holder.textView.setText(R.string.exo_track_selection_auto); + // hasSelectionOverride is true means there is an explicit track selection, not "Auto". + boolean hasSelectionOverride = false; + DefaultTrackSelector.Parameters parameters = checkNotNull(trackSelector).getParameters(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + TrackGroupArray trackGroups = checkNotNull(mappedTrackInfo).getTrackGroups(rendererIndex); + if (parameters.hasSelectionOverride(rendererIndex, trackGroups)) { + hasSelectionOverride = true; + break; + } + } + holder.checkView.setVisibility(hasSelectionOverride ? INVISIBLE : VISIBLE); + holder.itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + if (trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + parametersBuilder = parametersBuilder.clearSelectionOverrides(rendererIndex); + } + checkNotNull(trackSelector).setParameters(parametersBuilder); + } + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, + getResources().getString(R.string.exo_track_selection_auto)); + settingsWindow.dismiss(); + } + }); + } + + @Override + public void updateSettingsSubtext(String subtext) { + settingsAdapter.updateSubTexts(SETTINGS_AUDIO_TRACK_SELECTION_POSITION, subtext); + } + + @Override + public void init( + List rendererIndices, + List trackInfos, + MappedTrackInfo mappedTrackInfo) { + // Update subtext in settings menu with current audio track selection. + boolean hasSelectionOverride = false; + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + if (trackSelector != null + && trackSelector.getParameters().hasSelectionOverride(rendererIndex, trackGroups)) { + hasSelectionOverride = true; + break; + } + } + if (trackInfos.isEmpty()) { + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, + getResources().getString(R.string.exo_track_selection_none)); + // TODO(insun) : Make the audio item in main settings (settingsAdapater) + // to be non-clickable. + } else if (!hasSelectionOverride) { + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, + getResources().getString(R.string.exo_track_selection_auto)); + } else { + for (int i = 0; i < tracks.size(); i++) { + TrackInfo track = tracks.get(i); + if (track.selected) { + settingsAdapter.updateSubTexts( + SETTINGS_AUDIO_TRACK_SELECTION_POSITION, track.trackName); + break; + } + } + } + this.rendererIndices = rendererIndices; + this.tracks = trackInfos; + this.mappedTrackInfo = mappedTrackInfo; + } + } + + private abstract class TrackSelectionAdapter + extends RecyclerView.Adapter { + protected List rendererIndices; + protected List tracks; + protected @Nullable MappedTrackInfo mappedTrackInfo; + + public TrackSelectionAdapter() { + this.rendererIndices = new ArrayList<>(); + this.tracks = new ArrayList<>(); + this.mappedTrackInfo = null; + } + + public abstract void init( + List rendererIndices, List trackInfos, MappedTrackInfo mappedTrackInfo); + + @Override + public TrackSelectionViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View v = + LayoutInflater.from(getContext()) + .inflate(R.layout.exo_styled_sub_settings_list_item, null); + return new TrackSelectionViewHolder(v); + } + + public abstract void onBindViewHolderAtZeroPosition(TrackSelectionViewHolder holder); + + public abstract void updateSettingsSubtext(String subtext); + + @Override + public void onBindViewHolder(TrackSelectionViewHolder holder, int position) { + if (trackSelector == null || mappedTrackInfo == null) { + return; + } + if (position == 0) { + onBindViewHolderAtZeroPosition(holder); + } else { + TrackInfo track = tracks.get(position - 1); + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(track.rendererIndex); + boolean explicitlySelected = + checkNotNull(trackSelector) + .getParameters() + .hasSelectionOverride(track.rendererIndex, trackGroups) + && track.selected; + holder.textView.setText(track.trackName); + holder.checkView.setVisibility(explicitlySelected ? VISIBLE : INVISIBLE); + holder.itemView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + if (mappedTrackInfo != null && trackSelector != null) { + ParametersBuilder parametersBuilder = trackSelector.getParameters().buildUpon(); + for (int i = 0; i < rendererIndices.size(); i++) { + int rendererIndex = rendererIndices.get(i); + if (rendererIndex == track.rendererIndex) { + parametersBuilder = + parametersBuilder + .setSelectionOverride( + rendererIndex, + checkNotNull(mappedTrackInfo).getTrackGroups(rendererIndex), + new SelectionOverride(track.groupIndex, track.trackIndex)) + .setRendererDisabled(rendererIndex, false); + } else { + parametersBuilder = + parametersBuilder + .clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, true); + } + } + checkNotNull(trackSelector).setParameters(parametersBuilder); + updateSettingsSubtext(track.trackName); + settingsWindow.dismiss(); + } + } + }); + } + } + + @Override + public int getItemCount() { + return tracks.isEmpty() ? 0 : tracks.size() + 1; + } + + public void clear() { + tracks = Collections.emptyList(); + mappedTrackInfo = null; + } + } + + private static class TrackSelectionViewHolder extends RecyclerView.ViewHolder { + public final TextView textView; + public final View checkView; + + public TrackSelectionViewHolder(View itemView) { + super(itemView); + textView = itemView.findViewById(R.id.exo_text); + checkView = itemView.findViewById(R.id.exo_check); + } + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java new file mode 100644 index 0000000000..ef89023406 --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlViewLayoutManager.java @@ -0,0 +1,736 @@ +/* + * Copyright 2020 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.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.animation.LinearInterpolator; +import androidx.annotation.Nullable; +import java.util.ArrayList; + +/* package */ final class StyledPlayerControlViewLayoutManager + implements View.OnLayoutChangeListener { + private static final long ANIMATION_INTERVAL_MS = 2_000; + private static final long DURATION_FOR_HIDING_ANIMATION_MS = 250; + private static final long DURATION_FOR_SHOWING_ANIMATION_MS = 250; + + // Int for defining the UX state where all the views (TitleBar, ProgressBar, BottomBar) are + // all visible. + private static final int UX_STATE_ALL_VISIBLE = 0; + // Int for defining the UX state where only the ProgressBar view is visible. + private static final int UX_STATE_ONLY_PROGRESS_VISIBLE = 1; + // Int for defining the UX state where none of the views are visible. + private static final int UX_STATE_NONE_VISIBLE = 2; + // Int for defining the UX state where the views are being animated to be hidden. + private static final int UX_STATE_ANIMATING_HIDE = 3; + // Int for defining the UX state where the views are being animated to be shown. + private static final int UX_STATE_ANIMATING_SHOW = 4; + + private int uxState = UX_STATE_ALL_VISIBLE; + private boolean isMinimalMode; + private boolean needToShowBars; + private boolean disableAnimation = false; + + @Nullable private StyledPlayerControlView styledPlayerControlView; + + @Nullable private ViewGroup titleBar; + @Nullable private ViewGroup embeddedTransportControls; + @Nullable private ViewGroup bottomBar; + @Nullable private ViewGroup minimalControls; + @Nullable private ViewGroup basicControls; + @Nullable private ViewGroup extraControls; + @Nullable private ViewGroup extraControlsScrollView; + @Nullable private ViewGroup timeView; + @Nullable private View timeBar; + @Nullable private View overflowShowButton; + + @Nullable private AnimatorSet hideMainBarsAnimator; + @Nullable private AnimatorSet hideProgressBarAnimator; + @Nullable private AnimatorSet hideAllBarsAnimator; + @Nullable private AnimatorSet showMainBarsAnimator; + @Nullable private AnimatorSet showAllBarsAnimator; + @Nullable private ValueAnimator overflowShowAnimator; + @Nullable private ValueAnimator overflowHideAnimator; + + void show() { + if (this.styledPlayerControlView == null) { + return; + } + StyledPlayerControlView styledPlayerControlView = this.styledPlayerControlView; + if (!styledPlayerControlView.isVisible()) { + styledPlayerControlView.setVisibility(View.VISIBLE); + styledPlayerControlView.updateAll(); + styledPlayerControlView.requestPlayPauseFocus(); + } + styledPlayerControlView.post(showAllBars); + } + + void hide() { + if (styledPlayerControlView == null + || uxState == UX_STATE_ANIMATING_HIDE + || uxState == UX_STATE_NONE_VISIBLE) { + return; + } + removeHideCallbacks(); + if (isAnimationDisabled()) { + postDelayedRunnable(hideController, 0); + } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { + postDelayedRunnable(hideProgressBar, 0); + } else { + postDelayedRunnable(hideAllBars, 0); + } + } + + void setDisableAnimation(boolean disableAnimation) { + this.disableAnimation = disableAnimation; + } + + void resetHideCallbacks() { + if (uxState == UX_STATE_ANIMATING_HIDE) { + return; + } + removeHideCallbacks(); + int showTimeoutMs = + styledPlayerControlView != null ? styledPlayerControlView.getShowTimeoutMs() : 0; + if (showTimeoutMs > 0) { + if (isAnimationDisabled()) { + postDelayedRunnable(hideController, showTimeoutMs); + } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { + postDelayedRunnable(hideProgressBar, ANIMATION_INTERVAL_MS); + } else { + postDelayedRunnable(hideMainBars, showTimeoutMs); + } + } + } + + void removeHideCallbacks() { + if (styledPlayerControlView == null) { + return; + } + styledPlayerControlView.removeCallbacks(hideController); + styledPlayerControlView.removeCallbacks(hideAllBars); + styledPlayerControlView.removeCallbacks(hideMainBars); + styledPlayerControlView.removeCallbacks(hideProgressBar); + } + + void onViewAttached(StyledPlayerControlView v) { + styledPlayerControlView = v; + + v.addOnLayoutChangeListener(this); + + // Relating to Title Bar View + ViewGroup titleBar = v.findViewById(R.id.exo_title_bar); + + // Relating to Center View + ViewGroup centerView = v.findViewById(R.id.exo_center_view); + embeddedTransportControls = v.findViewById(R.id.exo_embedded_transport_controls); + + // Relating to Minimal Layout + minimalControls = v.findViewById(R.id.exo_minimal_controls); + + // Relating to Bottom Bar View + ViewGroup bottomBar = v.findViewById(R.id.exo_bottom_bar); + + // Relating to Bottom Bar Left View + timeView = v.findViewById(R.id.exo_time); + View timeBar = v.findViewById(R.id.exo_progress); + + // Relating to Bottom Bar Right View + basicControls = v.findViewById(R.id.exo_basic_controls); + extraControls = v.findViewById(R.id.exo_extra_controls); + extraControlsScrollView = v.findViewById(R.id.exo_extra_controls_scroll_view); + overflowShowButton = v.findViewById(R.id.exo_overflow_show); + View overflowHideButton = v.findViewById(R.id.exo_overflow_hide); + if (overflowShowButton != null && overflowHideButton != null) { + overflowShowButton.setOnClickListener(overflowListener); + overflowHideButton.setOnClickListener(overflowListener); + } + + this.titleBar = titleBar; + this.bottomBar = bottomBar; + this.timeBar = timeBar; + + Resources resources = v.getResources(); + float titleBarHeight = resources.getDimension(R.dimen.exo_title_bar_height); + float progressBarHeight = resources.getDimension(R.dimen.exo_custom_progress_thumb_size); + float bottomBarHeight = resources.getDimension(R.dimen.exo_bottom_bar_height); + + ValueAnimator fadeOutAnimator = ValueAnimator.ofFloat(1.0f, 0.0f); + fadeOutAnimator.setInterpolator(new LinearInterpolator()); + fadeOutAnimator.addUpdateListener( + animation -> { + float animatedValue = (float) animation.getAnimatedValue(); + + if (centerView != null) { + centerView.setAlpha(animatedValue); + } + if (minimalControls != null) { + minimalControls.setAlpha(animatedValue); + } + }); + fadeOutAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (timeBar instanceof DefaultTimeBar && !isMinimalMode) { + ((DefaultTimeBar) timeBar).hideScrubber(DURATION_FOR_HIDING_ANIMATION_MS); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (centerView != null) { + centerView.setVisibility(View.INVISIBLE); + } + if (minimalControls != null) { + minimalControls.setVisibility(View.INVISIBLE); + } + } + }); + + ValueAnimator fadeInAnimator = ValueAnimator.ofFloat(0.0f, 1.0f); + fadeInAnimator.setInterpolator(new LinearInterpolator()); + fadeInAnimator.addUpdateListener( + animation -> { + float animatedValue = (float) animation.getAnimatedValue(); + + if (centerView != null) { + centerView.setAlpha(animatedValue); + } + if (minimalControls != null) { + minimalControls.setAlpha(animatedValue); + } + }); + fadeInAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (centerView != null) { + centerView.setVisibility(View.VISIBLE); + } + if (minimalControls != null) { + minimalControls.setVisibility(isMinimalMode ? View.VISIBLE : View.INVISIBLE); + } + if (timeBar instanceof DefaultTimeBar && !isMinimalMode) { + ((DefaultTimeBar) timeBar).showScrubber(DURATION_FOR_SHOWING_ANIMATION_MS); + } + } + }); + + hideMainBarsAnimator = new AnimatorSet(); + hideMainBarsAnimator.setDuration(DURATION_FOR_HIDING_ANIMATION_MS); + hideMainBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_HIDE); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_ONLY_PROGRESS_VISIBLE); + if (needToShowBars) { + if (styledPlayerControlView != null) { + styledPlayerControlView.post(showAllBars); + } + needToShowBars = false; + } + } + }); + hideMainBarsAnimator + .play(fadeOutAnimator) + .with(ofTranslationY(0, -titleBarHeight, titleBar)) + .with(ofTranslationY(0, bottomBarHeight, timeBar)) + .with(ofTranslationY(0, bottomBarHeight, bottomBar)); + + hideProgressBarAnimator = new AnimatorSet(); + hideProgressBarAnimator.setDuration(DURATION_FOR_HIDING_ANIMATION_MS); + hideProgressBarAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_HIDE); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_NONE_VISIBLE); + if (needToShowBars) { + if (styledPlayerControlView != null) { + styledPlayerControlView.post(showAllBars); + } + needToShowBars = false; + } + } + }); + hideProgressBarAnimator + .play(ofTranslationY(bottomBarHeight, bottomBarHeight + progressBarHeight, timeBar)) + .with(ofTranslationY(bottomBarHeight, bottomBarHeight + progressBarHeight, bottomBar)); + + hideAllBarsAnimator = new AnimatorSet(); + hideAllBarsAnimator.setDuration(DURATION_FOR_HIDING_ANIMATION_MS); + hideAllBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_HIDE); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_NONE_VISIBLE); + if (needToShowBars) { + if (styledPlayerControlView != null) { + styledPlayerControlView.post(showAllBars); + } + needToShowBars = false; + } + } + }); + hideAllBarsAnimator + .play(fadeOutAnimator) + .with(ofTranslationY(0, -titleBarHeight, titleBar)) + .with(ofTranslationY(0, bottomBarHeight + progressBarHeight, timeBar)) + .with(ofTranslationY(0, bottomBarHeight + progressBarHeight, bottomBar)); + + showMainBarsAnimator = new AnimatorSet(); + showMainBarsAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + showMainBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_SHOW); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_ALL_VISIBLE); + } + }); + showMainBarsAnimator + .play(fadeInAnimator) + .with(ofTranslationY(-titleBarHeight, 0, titleBar)) + .with(ofTranslationY(bottomBarHeight, 0, timeBar)) + .with(ofTranslationY(bottomBarHeight, 0, bottomBar)); + + showAllBarsAnimator = new AnimatorSet(); + showAllBarsAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + showAllBarsAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setUxState(UX_STATE_ANIMATING_SHOW); + } + + @Override + public void onAnimationEnd(Animator animation) { + setUxState(UX_STATE_ALL_VISIBLE); + } + }); + showAllBarsAnimator + .play(fadeInAnimator) + .with(ofTranslationY(-titleBarHeight, 0, titleBar)) + .with(ofTranslationY(bottomBarHeight + progressBarHeight, 0, timeBar)) + .with(ofTranslationY(bottomBarHeight + progressBarHeight, 0, bottomBar)); + + overflowShowAnimator = ValueAnimator.ofFloat(0.0f, 1.0f); + overflowShowAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + overflowShowAnimator.addUpdateListener( + animation -> animateOverflow((float) animation.getAnimatedValue())); + overflowShowAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (extraControlsScrollView != null) { + extraControlsScrollView.setVisibility(View.VISIBLE); + extraControlsScrollView.setTranslationX(extraControlsScrollView.getWidth()); + extraControlsScrollView.scrollTo(extraControlsScrollView.getWidth(), 0); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (basicControls != null) { + basicControls.setVisibility(View.INVISIBLE); + } + } + }); + + overflowHideAnimator = ValueAnimator.ofFloat(1.0f, 0.0f); + overflowHideAnimator.setDuration(DURATION_FOR_SHOWING_ANIMATION_MS); + overflowHideAnimator.addUpdateListener( + animation -> animateOverflow((float) animation.getAnimatedValue())); + overflowHideAnimator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (basicControls != null) { + basicControls.setVisibility(View.VISIBLE); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (extraControlsScrollView != null) { + extraControlsScrollView.setVisibility(View.INVISIBLE); + } + } + }); + } + + void onViewDetached(StyledPlayerControlView v) { + v.removeOnLayoutChangeListener(this); + } + + boolean isFullyVisible() { + if (styledPlayerControlView == null) { + return false; + } + return uxState == UX_STATE_ALL_VISIBLE; + } + + private void setUxState(int uxState) { + int prevUxState = this.uxState; + this.uxState = uxState; + if (styledPlayerControlView != null) { + StyledPlayerControlView styledPlayerControlView = this.styledPlayerControlView; + if (uxState == UX_STATE_NONE_VISIBLE) { + styledPlayerControlView.setVisibility(View.GONE); + } else if (prevUxState == UX_STATE_NONE_VISIBLE) { + styledPlayerControlView.setVisibility(View.VISIBLE); + } + // TODO(insun): Notify specific uxState. Currently reuses legacy visibility listener for API + // compatibility. + if (prevUxState != uxState) { + styledPlayerControlView.notifyOnVisibilityChange(); + } + } + } + + private boolean isAnimationDisabled() { + return disableAnimation; + } + + private final Runnable showAllBars = + new Runnable() { + @Override + public void run() { + if (isAnimationDisabled()) { + setUxState(UX_STATE_ALL_VISIBLE); + resetHideCallbacks(); + return; + } + + switch (uxState) { + case UX_STATE_NONE_VISIBLE: + if (showAllBarsAnimator != null) { + showAllBarsAnimator.start(); + } + break; + case UX_STATE_ONLY_PROGRESS_VISIBLE: + if (showMainBarsAnimator != null) { + showMainBarsAnimator.start(); + } + break; + case UX_STATE_ANIMATING_HIDE: + needToShowBars = true; + break; + case UX_STATE_ANIMATING_SHOW: + return; + default: + break; + } + resetHideCallbacks(); + } + }; + + private final Runnable hideAllBars = + new Runnable() { + @Override + public void run() { + if (hideAllBarsAnimator == null) { + return; + } + hideAllBarsAnimator.start(); + } + }; + + private final Runnable hideMainBars = + new Runnable() { + @Override + public void run() { + if (hideMainBarsAnimator == null) { + return; + } + hideMainBarsAnimator.start(); + postDelayedRunnable(hideProgressBar, ANIMATION_INTERVAL_MS); + } + }; + + private final Runnable hideProgressBar = + new Runnable() { + @Override + public void run() { + if (hideProgressBarAnimator == null) { + return; + } + hideProgressBarAnimator.start(); + } + }; + + private final Runnable hideController = + new Runnable() { + @Override + public void run() { + setUxState(UX_STATE_NONE_VISIBLE); + } + }; + + private static ObjectAnimator ofTranslationY(float startValue, float endValue, View target) { + return ObjectAnimator.ofFloat(target, "translationY", startValue, endValue); + } + + private void postDelayedRunnable(Runnable runnable, long interval) { + if (styledPlayerControlView != null && interval >= 0) { + styledPlayerControlView.postDelayed(runnable, interval); + } + } + + private void animateOverflow(float animatedValue) { + if (extraControlsScrollView != null) { + int extraControlTranslationX = + (int) (extraControlsScrollView.getWidth() * (1 - animatedValue)); + extraControlsScrollView.setTranslationX(extraControlTranslationX); + } + + if (timeView != null) { + timeView.setAlpha(1 - animatedValue); + } + if (basicControls != null) { + basicControls.setAlpha(1 - animatedValue); + } + } + + private final OnClickListener overflowListener = + new OnClickListener() { + @Override + public void onClick(View v) { + resetHideCallbacks(); + if (v.getId() == R.id.exo_overflow_show && overflowShowAnimator != null) { + overflowShowAnimator.start(); + } else if (v.getId() == R.id.exo_overflow_hide && overflowHideAnimator != null) { + overflowHideAnimator.start(); + } + } + }; + + @Override + public void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + + boolean shouldBeMinimalMode = shouldBeMinimalMode(); + if (isMinimalMode != shouldBeMinimalMode) { + isMinimalMode = shouldBeMinimalMode; + v.post(() -> updateLayoutForSizeChange()); + } + boolean widthChanged = (right - left) != (oldRight - oldLeft); + if (!isMinimalMode && widthChanged) { + v.post(() -> onLayoutWidthChanged()); + } + } + + private static int getWidth(@Nullable View v) { + return (v != null ? v.getWidth() : 0); + } + + private static int getHeight(@Nullable View v) { + return (v != null ? v.getHeight() : 0); + } + + private boolean shouldBeMinimalMode() { + if (this.styledPlayerControlView == null) { + return isMinimalMode; + } + ViewGroup playerControlView = this.styledPlayerControlView; + + int width = + playerControlView.getWidth() + - playerControlView.getPaddingLeft() + - playerControlView.getPaddingRight(); + int height = + playerControlView.getHeight() + - playerControlView.getPaddingBottom() + - playerControlView.getPaddingTop(); + int defaultModeWidth = + Math.max( + getWidth(embeddedTransportControls), getWidth(timeView) + getWidth(overflowShowButton)); + int defaultModeHeight = + getHeight(embeddedTransportControls) + + getHeight(titleBar) + + getHeight(timeBar) + + getHeight(bottomBar); + + return (width <= defaultModeWidth || height <= defaultModeHeight); + } + + private void updateLayoutForSizeChange() { + if (this.styledPlayerControlView == null) { + return; + } + ViewGroup playerControlView = this.styledPlayerControlView; + + if (minimalControls != null) { + minimalControls.setVisibility(isMinimalMode ? View.VISIBLE : View.INVISIBLE); + } + + View fullScreenButton = playerControlView.findViewById(R.id.exo_fullscreen); + if (fullScreenButton != null) { + ViewGroup parent = (ViewGroup) fullScreenButton.getParent(); + parent.removeView(fullScreenButton); + + if (isMinimalMode && minimalControls != null) { + minimalControls.addView(fullScreenButton); + } else if (!isMinimalMode && basicControls != null) { + int index = Math.max(0, basicControls.getChildCount() - 1); + basicControls.addView(fullScreenButton, index); + } else { + parent.addView(fullScreenButton); + } + } + if (timeBar != null) { + View timeBar = this.timeBar; + MarginLayoutParams timeBarParams = (MarginLayoutParams) timeBar.getLayoutParams(); + int timeBarMarginBottom = + playerControlView + .getResources() + .getDimensionPixelSize(R.dimen.exo_custom_progress_margin_bottom); + timeBarParams.bottomMargin = (isMinimalMode ? 0 : timeBarMarginBottom); + timeBar.setLayoutParams(timeBarParams); + if (timeBar instanceof DefaultTimeBar + && uxState != UX_STATE_ANIMATING_HIDE + && uxState != UX_STATE_ANIMATING_SHOW) { + if (isMinimalMode || uxState != UX_STATE_ALL_VISIBLE) { + ((DefaultTimeBar) timeBar).hideScrubber(); + } else { + ((DefaultTimeBar) timeBar).showScrubber(); + } + } + } + + int[] idsToHideInMinimalMode = { + R.id.exo_title_bar, + R.id.exo_bottom_bar, + R.id.exo_prev, + R.id.exo_next, + R.id.exo_rew, + R.id.exo_rew_with_amount, + R.id.exo_ffwd, + R.id.exo_ffwd_with_amount + }; + for (int id : idsToHideInMinimalMode) { + View v = playerControlView.findViewById(id); + if (v != null) { + v.setVisibility(isMinimalMode ? View.INVISIBLE : View.VISIBLE); + } + } + } + + private void onLayoutWidthChanged() { + if (basicControls == null || extraControls == null) { + return; + } + ViewGroup basicControls = this.basicControls; + ViewGroup extraControls = this.extraControls; + + int width = + (styledPlayerControlView != null + ? styledPlayerControlView.getWidth() + - styledPlayerControlView.getPaddingLeft() + - styledPlayerControlView.getPaddingRight() + : 0); + int basicBottomBarWidth = getWidth(timeView); + for (int i = 0; i < basicControls.getChildCount(); ++i) { + basicBottomBarWidth += basicControls.getChildAt(i).getWidth(); + } + + // BasicControls keeps overflow button at least. + int minBasicControlsChildCount = 1; + // ExtraControls keeps overflow button and settings button at least. + int minExtraControlsChildCount = 2; + + if (basicBottomBarWidth > width) { + // move control views from basicControls to extraControls + ArrayList movingChildren = new ArrayList<>(); + int movingWidth = 0; + int endIndex = basicControls.getChildCount() - minBasicControlsChildCount; + for (int index = 0; index < endIndex; index++) { + View child = basicControls.getChildAt(index); + movingWidth += child.getWidth(); + movingChildren.add(child); + if (basicBottomBarWidth - movingWidth <= width) { + break; + } + } + + if (!movingChildren.isEmpty()) { + basicControls.removeViews(0, movingChildren.size()); + + for (View child : movingChildren) { + int index = extraControls.getChildCount() - minExtraControlsChildCount; + extraControls.addView(child, index); + } + } + + } else { + // move controls from extraControls to basicControls if possible, else do nothing + ArrayList movingChildren = new ArrayList<>(); + int movingWidth = 0; + int startIndex = extraControls.getChildCount() - minExtraControlsChildCount - 1; + for (int index = startIndex; index >= 0; index--) { + View child = extraControls.getChildAt(index); + movingWidth += child.getWidth(); + if (basicBottomBarWidth + movingWidth > width) { + break; + } + movingChildren.add(child); + } + + if (!movingChildren.isEmpty()) { + extraControls.removeViews(startIndex - movingChildren.size() + 1, movingChildren.size()); + + for (View child : movingChildren) { + basicControls.addView(child, 0); + } + } + } + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerView.java new file mode 100644 index 0000000000..46849979c8 --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerView.java @@ -0,0 +1,1709 @@ +/* + * Copyright 2019 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.ui; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackPreparer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.flac.PictureFrame; +import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.TextOutput; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; +import com.google.android.exoplayer2.ui.spherical.SingleTapListener; +import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.ErrorMessageProvider; +import com.google.android.exoplayer2.util.RepeatModeUtil; +import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.video.VideoDecoderGLSurfaceView; +import com.google.android.exoplayer2.video.VideoListener; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * A high level view for {@link Player} media playbacks. It displays video, subtitles and album art + * during playback, and displays playback controls using a {@link StyledPlayerControlView}. + * + *

A StyledPlayerView can be customized by setting attributes (or calling corresponding methods), + * overriding drawables, overriding the view's layout file, or by specifying a custom view layout + * file. + * + *

Attributes

+ * + * The following attributes can be set on a StyledPlayerView when used in a layout XML file: + * + * + * + *

Overriding drawables

+ * + * The drawables used by {@link StyledPlayerControlView} (with its default layout file) can be + * overridden by drawables with the same names defined in your application. See the {@link + * StyledPlayerControlView} documentation for a list of drawables that can be overridden. + * + *

Overriding the layout file

+ * + * To customize the layout of StyledPlayerView throughout your app, or just for certain + * configurations, you can define {@code exo_player_view.xml} layout files in your application + * {@code res/layout*} directories. These layouts will override the one provided by the ExoPlayer + * library, and will be inflated for use by StyledPlayerView. The view identifies and binds its + * children by looking for the following ids: + * + * + * + *

All child views are optional and so can be omitted if not required, however where defined they + * must be of the expected type. + * + *

Specifying a custom layout file

+ * + * Defining your own {@code exo_styled_player_view.xml} is useful to customize the layout of + * StyledPlayerView throughout your application. It's also possible to customize the layout for a + * single instance in a layout file. This is achieved by setting the {@code player_layout_id} + * attribute on a StyledPlayerView. This will cause the specified layout to be inflated instead of + * {@code exo_styled_player_view.xml} for only the instance on which the attribute is set. + */ +public class StyledPlayerView extends FrameLayout implements AdsLoader.AdViewProvider { + + // LINT.IfChange + /** + * Determines when the buffering view is shown. One of {@link #SHOW_BUFFERING_NEVER}, {@link + * #SHOW_BUFFERING_WHEN_PLAYING} or {@link #SHOW_BUFFERING_ALWAYS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({SHOW_BUFFERING_NEVER, SHOW_BUFFERING_WHEN_PLAYING, SHOW_BUFFERING_ALWAYS}) + public @interface ShowBuffering {} + /** The buffering view is never shown. */ + public static final int SHOW_BUFFERING_NEVER = 0; + /** + * The buffering view is shown when the player is in the {@link Player#STATE_BUFFERING buffering} + * state and {@link Player#getPlayWhenReady() playWhenReady} is {@code true}. + */ + public static final int SHOW_BUFFERING_WHEN_PLAYING = 1; + /** + * The buffering view is always shown when the player is in the {@link Player#STATE_BUFFERING + * buffering} state. + */ + public static final int SHOW_BUFFERING_ALWAYS = 2; + // LINT.ThenChange(../../../../../../res/values/attrs.xml) + + // LINT.IfChange + private static final int SURFACE_TYPE_NONE = 0; + private static final int SURFACE_TYPE_SURFACE_VIEW = 1; + private static final int SURFACE_TYPE_TEXTURE_VIEW = 2; + private static final int SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW = 3; + private static final int SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW = 4; + // LINT.ThenChange(../../../../../../res/values/attrs.xml) + + private final ComponentListener componentListener; + @Nullable private final AspectRatioFrameLayout contentFrame; + @Nullable private final View shutterView; + @Nullable private final View surfaceView; + @Nullable private final ImageView artworkView; + @Nullable private final SubtitleView subtitleView; + @Nullable private final View bufferingView; + @Nullable private final TextView errorMessageView; + @Nullable private final StyledPlayerControlView controller; + @Nullable private final FrameLayout adOverlayFrameLayout; + @Nullable private final FrameLayout overlayFrameLayout; + + @Nullable private Player player; + private boolean useController; + @Nullable private StyledPlayerControlView.VisibilityListener controllerVisibilityListener; + private boolean useArtwork; + @Nullable private Drawable defaultArtwork; + private @ShowBuffering int showBuffering; + private boolean keepContentOnPlayerReset; + private boolean useSensorRotation; + @Nullable private ErrorMessageProvider errorMessageProvider; + @Nullable private CharSequence customErrorMessage; + private int controllerShowTimeoutMs; + private boolean controllerAutoShow; + private boolean controllerHideDuringAds; + private boolean controllerHideOnTouch; + private int textureViewRotation; + private boolean isTouching; + private static final int PICTURE_TYPE_FRONT_COVER = 3; + private static final int PICTURE_TYPE_NOT_SET = -1; + + public StyledPlayerView(Context context) { + this(context, /* attrs= */ null); + } + + public StyledPlayerView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, /* defStyleAttr= */ 0); + } + + @SuppressWarnings({"nullness:argument.type.incompatible", "nullness:method.invocation.invalid"}) + public StyledPlayerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + componentListener = new ComponentListener(); + + if (isInEditMode()) { + contentFrame = null; + shutterView = null; + surfaceView = null; + artworkView = null; + subtitleView = null; + bufferingView = null; + errorMessageView = null; + controller = null; + adOverlayFrameLayout = null; + overlayFrameLayout = null; + ImageView logo = new ImageView(context); + if (Util.SDK_INT >= 23) { + configureEditModeLogoV23(getResources(), logo); + } else { + configureEditModeLogo(getResources(), logo); + } + addView(logo); + return; + } + + boolean shutterColorSet = false; + int shutterColor = 0; + int playerLayoutId = R.layout.exo_player_view; + boolean useArtwork = true; + int defaultArtworkId = 0; + boolean useController = true; + int surfaceType = SURFACE_TYPE_SURFACE_VIEW; + int resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + int controllerShowTimeoutMs = StyledPlayerControlView.DEFAULT_SHOW_TIMEOUT_MS; + boolean controllerHideOnTouch = true; + boolean controllerAutoShow = true; + boolean controllerHideDuringAds = true; + int showBuffering = SHOW_BUFFERING_NEVER; + useSensorRotation = true; + if (attrs != null) { + TypedArray a = + context.getTheme().obtainStyledAttributes(attrs, R.styleable.StyledPlayerView, 0, 0); + try { + shutterColorSet = a.hasValue(R.styleable.StyledPlayerView_shutter_background_color); + shutterColor = + a.getColor(R.styleable.StyledPlayerView_shutter_background_color, shutterColor); + playerLayoutId = + a.getResourceId(R.styleable.StyledPlayerView_player_layout_id, playerLayoutId); + useArtwork = a.getBoolean(R.styleable.StyledPlayerView_use_artwork, useArtwork); + defaultArtworkId = + a.getResourceId(R.styleable.StyledPlayerView_default_artwork, defaultArtworkId); + useController = a.getBoolean(R.styleable.StyledPlayerView_use_controller, useController); + surfaceType = a.getInt(R.styleable.StyledPlayerView_surface_type, surfaceType); + resizeMode = a.getInt(R.styleable.StyledPlayerView_resize_mode, resizeMode); + controllerShowTimeoutMs = + a.getInt(R.styleable.StyledPlayerView_show_timeout, controllerShowTimeoutMs); + controllerHideOnTouch = + a.getBoolean(R.styleable.StyledPlayerView_hide_on_touch, controllerHideOnTouch); + controllerAutoShow = + a.getBoolean(R.styleable.StyledPlayerView_auto_show, controllerAutoShow); + showBuffering = a.getInteger(R.styleable.StyledPlayerView_show_buffering, showBuffering); + keepContentOnPlayerReset = + a.getBoolean( + R.styleable.StyledPlayerView_keep_content_on_player_reset, + keepContentOnPlayerReset); + controllerHideDuringAds = + a.getBoolean(R.styleable.StyledPlayerView_hide_during_ads, controllerHideDuringAds); + useSensorRotation = + a.getBoolean(R.styleable.StyledPlayerView_use_sensor_rotation, useSensorRotation); + } finally { + a.recycle(); + } + } + + LayoutInflater.from(context).inflate(playerLayoutId, this); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + + // Content frame. + contentFrame = findViewById(R.id.exo_content_frame); + if (contentFrame != null) { + setResizeModeRaw(contentFrame, resizeMode); + } + + // Shutter view. + shutterView = findViewById(R.id.exo_shutter); + if (shutterView != null && shutterColorSet) { + shutterView.setBackgroundColor(shutterColor); + } + + // Create a surface view and insert it into the content frame, if there is one. + if (contentFrame != null && surfaceType != SURFACE_TYPE_NONE) { + ViewGroup.LayoutParams params = + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + switch (surfaceType) { + case SURFACE_TYPE_TEXTURE_VIEW: + surfaceView = new TextureView(context); + break; + case SURFACE_TYPE_SPHERICAL_GL_SURFACE_VIEW: + SphericalGLSurfaceView sphericalGLSurfaceView = new SphericalGLSurfaceView(context); + sphericalGLSurfaceView.setSingleTapListener(componentListener); + sphericalGLSurfaceView.setUseSensorRotation(useSensorRotation); + surfaceView = sphericalGLSurfaceView; + break; + case SURFACE_TYPE_VIDEO_DECODER_GL_SURFACE_VIEW: + surfaceView = new VideoDecoderGLSurfaceView(context); + break; + default: + surfaceView = new SurfaceView(context); + break; + } + surfaceView.setLayoutParams(params); + contentFrame.addView(surfaceView, 0); + } else { + surfaceView = null; + } + + // Ad overlay frame layout. + adOverlayFrameLayout = findViewById(R.id.exo_ad_overlay); + + // Overlay frame layout. + overlayFrameLayout = findViewById(R.id.exo_overlay); + + // Artwork view. + artworkView = findViewById(R.id.exo_artwork); + this.useArtwork = useArtwork && artworkView != null; + if (defaultArtworkId != 0) { + defaultArtwork = ContextCompat.getDrawable(getContext(), defaultArtworkId); + } + + // Subtitle view. + subtitleView = findViewById(R.id.exo_subtitles); + if (subtitleView != null) { + subtitleView.setUserDefaultStyle(); + subtitleView.setUserDefaultTextSize(); + } + + // Buffering view. + bufferingView = findViewById(R.id.exo_buffering); + if (bufferingView != null) { + bufferingView.setVisibility(View.GONE); + } + this.showBuffering = showBuffering; + + // Error message view. + errorMessageView = findViewById(R.id.exo_error_message); + if (errorMessageView != null) { + errorMessageView.setVisibility(View.GONE); + } + + // Playback control view. + StyledPlayerControlView customController = findViewById(R.id.exo_controller); + View controllerPlaceholder = findViewById(R.id.exo_controller_placeholder); + if (customController != null) { + this.controller = customController; + } else if (controllerPlaceholder != null) { + // Propagate attrs as playbackAttrs so that PlayerControlView's custom attributes are + // transferred, but standard attributes (e.g. background) are not. + this.controller = new StyledPlayerControlView(context, null, 0, attrs); + controller.setId(R.id.exo_controller); + controller.setLayoutParams(controllerPlaceholder.getLayoutParams()); + ViewGroup parent = ((ViewGroup) controllerPlaceholder.getParent()); + int controllerIndex = parent.indexOfChild(controllerPlaceholder); + parent.removeView(controllerPlaceholder); + parent.addView(controller, controllerIndex); + } else { + this.controller = null; + } + this.controllerShowTimeoutMs = controller != null ? controllerShowTimeoutMs : 0; + this.controllerHideOnTouch = controllerHideOnTouch; + this.controllerAutoShow = controllerAutoShow; + this.controllerHideDuringAds = controllerHideDuringAds; + this.useController = useController && controller != null; + hideController(); + updateContentDescription(); + if (controller != null) { + controller.addVisibilityListener(/* listener= */ componentListener); + } + } + + /** + * Switches the view targeted by a given {@link Player}. + * + * @param player The player whose target view is being switched. + * @param oldPlayerView The old view to detach from the player. + * @param newPlayerView The new view to attach to the player. + */ + public static void switchTargetView( + Player player, + @Nullable StyledPlayerView oldPlayerView, + @Nullable StyledPlayerView newPlayerView) { + if (oldPlayerView == newPlayerView) { + return; + } + // We attach the new view before detaching the old one because this ordering allows the player + // to swap directly from one surface to another, without transitioning through a state where no + // surface is attached. This is significantly more efficient and achieves a more seamless + // transition when using platform provided video decoders. + if (newPlayerView != null) { + newPlayerView.setPlayer(player); + } + if (oldPlayerView != null) { + oldPlayerView.setPlayer(null); + } + } + + /** Returns the player currently set on this view, or null if no player is set. */ + @Nullable + public Player getPlayer() { + return player; + } + + /** + * Set the {@link Player} to use. + * + *

To transition a {@link Player} from targeting one view to another, it's recommended to use + * {@link #switchTargetView(Player, StyledPlayerView, StyledPlayerView)} rather than this method. + * If you do wish to use this method directly, be sure to attach the player to the new view + * before calling {@code setPlayer(null)} to detach it from the old one. This ordering is + * significantly more efficient and may allow for more seamless transitions. + * + * @param player The {@link Player} to use, or {@code null} to detach the current player. Only + * players which are accessed on the main thread are supported ({@code + * player.getApplicationLooper() == Looper.getMainLooper()}). + */ + public void setPlayer(@Nullable Player player) { + Assertions.checkState(Looper.myLooper() == Looper.getMainLooper()); + Assertions.checkArgument( + player == null || player.getApplicationLooper() == Looper.getMainLooper()); + if (this.player == player) { + return; + } + @Nullable Player oldPlayer = this.player; + if (oldPlayer != null) { + oldPlayer.removeListener(componentListener); + @Nullable Player.VideoComponent oldVideoComponent = oldPlayer.getVideoComponent(); + if (oldVideoComponent != null) { + oldVideoComponent.removeVideoListener(componentListener); + if (surfaceView instanceof TextureView) { + oldVideoComponent.clearVideoTextureView((TextureView) surfaceView); + } else if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).setVideoComponent(null); + } else if (surfaceView instanceof VideoDecoderGLSurfaceView) { + oldVideoComponent.setVideoDecoderOutputBufferRenderer(null); + } else if (surfaceView instanceof SurfaceView) { + oldVideoComponent.clearVideoSurfaceView((SurfaceView) surfaceView); + } + } + @Nullable Player.TextComponent oldTextComponent = oldPlayer.getTextComponent(); + if (oldTextComponent != null) { + oldTextComponent.removeTextOutput(componentListener); + } + } + if (subtitleView != null) { + subtitleView.setCues(null); + } + this.player = player; + if (useController()) { + controller.setPlayer(player); + } + updateBuffering(); + updateErrorMessage(); + updateForCurrentTrackSelections(/* isNewPlayer= */ true); + if (player != null) { + @Nullable Player.VideoComponent newVideoComponent = player.getVideoComponent(); + if (newVideoComponent != null) { + if (surfaceView instanceof TextureView) { + newVideoComponent.setVideoTextureView((TextureView) surfaceView); + } else if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).setVideoComponent(newVideoComponent); + } else if (surfaceView instanceof VideoDecoderGLSurfaceView) { + newVideoComponent.setVideoDecoderOutputBufferRenderer( + ((VideoDecoderGLSurfaceView) surfaceView).getVideoDecoderOutputBufferRenderer()); + } else if (surfaceView instanceof SurfaceView) { + newVideoComponent.setVideoSurfaceView((SurfaceView) surfaceView); + } + newVideoComponent.addVideoListener(componentListener); + } + @Nullable Player.TextComponent newTextComponent = player.getTextComponent(); + if (newTextComponent != null) { + newTextComponent.addTextOutput(componentListener); + if (subtitleView != null) { + subtitleView.setCues(newTextComponent.getCurrentCues()); + } + } + player.addListener(componentListener); + maybeShowController(false); + } else { + hideController(); + } + } + + @Override + public void setVisibility(int visibility) { + super.setVisibility(visibility); + if (surfaceView instanceof SurfaceView) { + // Work around https://github.com/google/ExoPlayer/issues/3160. + surfaceView.setVisibility(visibility); + } + } + + /** + * Sets the {@link ResizeMode}. + * + * @param resizeMode The {@link ResizeMode}. + */ + public void setResizeMode(@ResizeMode int resizeMode) { + Assertions.checkStateNotNull(contentFrame); + contentFrame.setResizeMode(resizeMode); + } + + /** Returns the {@link ResizeMode}. */ + public @ResizeMode int getResizeMode() { + Assertions.checkStateNotNull(contentFrame); + return contentFrame.getResizeMode(); + } + + /** Returns whether artwork is displayed if present in the media. */ + public boolean getUseArtwork() { + return useArtwork; + } + + /** + * Sets whether artwork is displayed if present in the media. + * + * @param useArtwork Whether artwork is displayed. + */ + public void setUseArtwork(boolean useArtwork) { + Assertions.checkState(!useArtwork || artworkView != null); + if (this.useArtwork != useArtwork) { + this.useArtwork = useArtwork; + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + } + + /** Returns the default artwork to display. */ + @Nullable + public Drawable getDefaultArtwork() { + return defaultArtwork; + } + + /** + * Sets the default artwork to display if {@code useArtwork} is {@code true} and no artwork is + * present in the media. + * + * @param defaultArtwork the default artwork to display + */ + public void setDefaultArtwork(@Nullable Drawable defaultArtwork) { + if (this.defaultArtwork != defaultArtwork) { + this.defaultArtwork = defaultArtwork; + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + } + + /** Returns whether the playback controls can be shown. */ + public boolean getUseController() { + return useController; + } + + /** + * Sets whether the playback controls can be shown. If set to {@code false} the playback controls + * are never visible and are disconnected from the player. + * + * @param useController Whether the playback controls can be shown. + */ + public void setUseController(boolean useController) { + Assertions.checkState(!useController || controller != null); + if (this.useController == useController) { + return; + } + this.useController = useController; + if (useController()) { + controller.setPlayer(player); + } else if (controller != null) { + controller.hide(); + controller.setPlayer(/* player= */ null); + } + updateContentDescription(); + } + + /** + * Sets the background color of the {@code exo_shutter} view. + * + * @param color The background color. + */ + public void setShutterBackgroundColor(int color) { + if (shutterView != null) { + shutterView.setBackgroundColor(color); + } + } + + /** + * Sets whether the currently displayed video frame or media artwork is kept visible when the + * player is reset. A player reset is defined to mean the player being re-prepared with different + * media, the player transitioning to unprepared media, {@link Player#stop(boolean)} being called + * with {@code reset=true}, or the player being replaced or cleared by calling {@link + * #setPlayer(Player)}. + * + *

If enabled, the currently displayed video frame or media artwork will be kept visible until + * the player set on the view has been successfully prepared with new media and loaded enough of + * it to have determined the available tracks. Hence enabling this option allows transitioning + * from playing one piece of media to another, or from using one player instance to another, + * without clearing the view's content. + * + *

If disabled, the currently displayed video frame or media artwork will be hidden as soon as + * the player is reset. Note that the video frame is hidden by making {@code exo_shutter} visible. + * Hence the video frame will not be hidden if using a custom layout that omits this view. + * + * @param keepContentOnPlayerReset Whether the currently displayed video frame or media artwork is + * kept visible when the player is reset. + */ + public void setKeepContentOnPlayerReset(boolean keepContentOnPlayerReset) { + if (this.keepContentOnPlayerReset != keepContentOnPlayerReset) { + this.keepContentOnPlayerReset = keepContentOnPlayerReset; + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + } + + /** + * Sets whether to use the orientation sensor for rotation during spherical playbacks (if + * available) + * + * @param useSensorRotation Whether to use the orientation sensor for rotation during spherical + * playbacks. + */ + public void setUseSensorRotation(boolean useSensorRotation) { + if (this.useSensorRotation != useSensorRotation) { + this.useSensorRotation = useSensorRotation; + if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).setUseSensorRotation(useSensorRotation); + } + } + } + + /** + * Sets whether a buffering spinner is displayed when the player is in the buffering state. The + * buffering spinner is not displayed by default. + * + * @param showBuffering The mode that defines when the buffering spinner is displayed. One of + * {@link #SHOW_BUFFERING_NEVER}, {@link #SHOW_BUFFERING_WHEN_PLAYING} and {@link + * #SHOW_BUFFERING_ALWAYS}. + */ + public void setShowBuffering(@ShowBuffering int showBuffering) { + if (this.showBuffering != showBuffering) { + this.showBuffering = showBuffering; + updateBuffering(); + } + } + + /** + * Sets the optional {@link ErrorMessageProvider}. + * + * @param errorMessageProvider The error message provider. + */ + public void setErrorMessageProvider( + @Nullable ErrorMessageProvider errorMessageProvider) { + if (this.errorMessageProvider != errorMessageProvider) { + this.errorMessageProvider = errorMessageProvider; + updateErrorMessage(); + } + } + + /** + * Sets a custom error message to be displayed by the view. The error message will be displayed + * permanently, unless it is cleared by passing {@code null} to this method. + * + * @param message The message to display, or {@code null} to clear a previously set message. + */ + public void setCustomErrorMessage(@Nullable CharSequence message) { + Assertions.checkState(errorMessageView != null); + customErrorMessage = message; + updateErrorMessage(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (player != null && player.isPlayingAd()) { + return super.dispatchKeyEvent(event); + } + + boolean isDpadKey = isDpadKey(event.getKeyCode()); + boolean handled = false; + if (isDpadKey && useController() && !controller.isFullyVisible()) { + // Handle the key event by showing the controller. + maybeShowController(true); + handled = true; + } else if (dispatchMediaKeyEvent(event) || super.dispatchKeyEvent(event)) { + // The key event was handled as a media key or by the super class. We should also show the + // controller, or extend its show timeout if already visible. + maybeShowController(true); + handled = true; + } else if (isDpadKey && useController()) { + // The key event wasn't handled, but we should extend the controller's show timeout. + maybeShowController(true); + } + return handled; + } + + /** + * Called to process media key events. Any {@link KeyEvent} can be passed but only media key + * events will be handled. Does nothing if playback controls are disabled. + * + * @param event A key event. + * @return Whether the key event was handled. + */ + public boolean dispatchMediaKeyEvent(KeyEvent event) { + return useController() && controller.dispatchMediaKeyEvent(event); + } + + /** Returns whether the controller is currently fully visible. */ + public boolean isControllerFullyVisible() { + return controller != null && controller.isFullyVisible(); + } + + /** + * Shows the playback controls. Does nothing if playback controls are disabled. + * + *

The playback controls are automatically hidden during playback after {{@link + * #getControllerShowTimeoutMs()}}. They are shown indefinitely when playback has not started yet, + * is paused, has ended or failed. + */ + public void showController() { + showController(shouldShowControllerIndefinitely()); + } + + /** Hides the playback controls. Does nothing if playback controls are disabled. */ + public void hideController() { + if (controller != null) { + controller.hide(); + } + } + + /** + * Returns the playback controls timeout. The playback controls are automatically hidden after + * this duration of time has elapsed without user input and with playback or buffering in + * progress. + * + * @return The timeout in milliseconds. A non-positive value will cause the controller to remain + * visible indefinitely. + */ + public int getControllerShowTimeoutMs() { + return controllerShowTimeoutMs; + } + + /** + * Sets the playback controls timeout. The playback controls are automatically hidden after this + * duration of time has elapsed without user input and with playback or buffering in progress. + * + * @param controllerShowTimeoutMs The timeout in milliseconds. A non-positive value will cause the + * controller to remain visible indefinitely. + */ + public void setControllerShowTimeoutMs(int controllerShowTimeoutMs) { + Assertions.checkStateNotNull(controller); + this.controllerShowTimeoutMs = controllerShowTimeoutMs; + if (controller.isFullyVisible()) { + // Update the controller's timeout if necessary. + showController(); + } + } + + /** Returns whether the playback controls are hidden by touch events. */ + public boolean getControllerHideOnTouch() { + return controllerHideOnTouch; + } + + /** + * Sets whether the playback controls are hidden by touch events. + * + * @param controllerHideOnTouch Whether the playback controls are hidden by touch events. + */ + public void setControllerHideOnTouch(boolean controllerHideOnTouch) { + Assertions.checkStateNotNull(controller); + this.controllerHideOnTouch = controllerHideOnTouch; + updateContentDescription(); + } + + /** + * Returns whether the playback controls are automatically shown when playback starts, pauses, + * ends, or fails. If set to false, the playback controls can be manually operated with {@link + * #showController()} and {@link #hideController()}. + */ + public boolean getControllerAutoShow() { + return controllerAutoShow; + } + + /** + * Sets whether the playback controls are automatically shown when playback starts, pauses, ends, + * or fails. If set to false, the playback controls can be manually operated with {@link + * #showController()} and {@link #hideController()}. + * + * @param controllerAutoShow Whether the playback controls are allowed to show automatically. + */ + public void setControllerAutoShow(boolean controllerAutoShow) { + this.controllerAutoShow = controllerAutoShow; + } + + /** + * Sets whether the playback controls are hidden when ads are playing. Controls are always shown + * during ads if they are enabled and the player is paused. + * + * @param controllerHideDuringAds Whether the playback controls are hidden when ads are playing. + */ + public void setControllerHideDuringAds(boolean controllerHideDuringAds) { + this.controllerHideDuringAds = controllerHideDuringAds; + } + + /** + * Set the {@link StyledPlayerControlView.VisibilityListener}. + * + * @param listener The listener to be notified about visibility changes, or null to remove the + * current listener. + */ + public void setControllerVisibilityListener( + @Nullable StyledPlayerControlView.VisibilityListener listener) { + Assertions.checkStateNotNull(controller); + if (this.controllerVisibilityListener == listener) { + return; + } + if (this.controllerVisibilityListener != null) { + controller.removeVisibilityListener(this.controllerVisibilityListener); + } + this.controllerVisibilityListener = listener; + if (listener != null) { + controller.addVisibilityListener(listener); + } + } + + /** + * Sets the {@link StyledPlayerControlView.OnFullScreenModeChangedListener}. + * + * @param listener The listener to be notified when the fullscreen button is clicked, or null to + * remove the current listener and hide the fullscreen button. + */ + public void setControllerOnFullScreenModeChangedListener( + @Nullable StyledPlayerControlView.OnFullScreenModeChangedListener listener) { + Assertions.checkStateNotNull(controller); + controller.setOnFullScreenModeChangedListener(listener); + } + + /** + * Sets the {@link PlaybackPreparer}. + * + * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback + * preparer. + */ + public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { + Assertions.checkStateNotNull(controller); + controller.setPlaybackPreparer(playbackPreparer); + } + + /** + * Sets the {@link ControlDispatcher}. + * + * @param controlDispatcher The {@link ControlDispatcher}. + */ + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + Assertions.checkStateNotNull(controller); + controller.setControlDispatcher(controlDispatcher); + } + + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + Assertions.checkStateNotNull(controller); + controller.setShowRewindButton(showRewindButton); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + Assertions.checkStateNotNull(controller); + controller.setShowFastForwardButton(showFastForwardButton); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + Assertions.checkStateNotNull(controller); + controller.setShowPreviousButton(showPreviousButton); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + Assertions.checkStateNotNull(controller); + controller.setShowNextButton(showNextButton); + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setRewindIncrementMs(int rewindMs) { + Assertions.checkStateNotNull(controller); + controller.setRewindIncrementMs(rewindMs); + } + + /** + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link + * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public void setFastForwardIncrementMs(int fastForwardMs) { + Assertions.checkStateNotNull(controller); + controller.setFastForwardIncrementMs(fastForwardMs); + } + + /** + * Sets which repeat toggle modes are enabled. + * + * @param repeatToggleModes A set of {@link RepeatModeUtil.RepeatToggleModes}. + */ + public void setRepeatToggleModes(@RepeatModeUtil.RepeatToggleModes int repeatToggleModes) { + Assertions.checkStateNotNull(controller); + controller.setRepeatToggleModes(repeatToggleModes); + } + + /** + * Sets whether the shuffle button is shown. + * + * @param showShuffleButton Whether the shuffle button is shown. + */ + public void setShowShuffleButton(boolean showShuffleButton) { + Assertions.checkStateNotNull(controller); + controller.setShowShuffleButton(showShuffleButton); + } + + /** + * Sets whether the subtitle button is shown. + * + * @param showSubtitleButton Whether the subtitle button is shown. + */ + public void setShowSubtitleButton(boolean showSubtitleButton) { + Assertions.checkStateNotNull(controller); + controller.setShowSubtitleButton(showSubtitleButton); + } + + /** + * Sets whether the vr button is shown. + * + * @param showVrButton Whether the vr button is shown. + */ + public void setShowVrButton(boolean showVrButton) { + Assertions.checkStateNotNull(controller); + controller.setShowVrButton(showVrButton); + } + + /** + * Sets whether the time bar should show all windows, as opposed to just the current one. + * + * @param showMultiWindowTimeBar Whether to show all windows. + */ + public void setShowMultiWindowTimeBar(boolean showMultiWindowTimeBar) { + Assertions.checkStateNotNull(controller); + controller.setShowMultiWindowTimeBar(showMultiWindowTimeBar); + } + + /** + * Sets the millisecond positions of extra ad markers relative to the start of the window (or + * timeline, if in multi-window mode) and whether each extra ad has been played or not. The + * markers are shown in addition to any ad markers for ads in the player's timeline. + * + * @param extraAdGroupTimesMs The millisecond timestamps of the extra ad markers to show, or + * {@code null} to show no extra ad markers. + * @param extraPlayedAdGroups Whether each ad has been played, or {@code null} to show no extra ad + * markers. + */ + public void setExtraAdGroupMarkers( + @Nullable long[] extraAdGroupTimesMs, @Nullable boolean[] extraPlayedAdGroups) { + Assertions.checkStateNotNull(controller); + controller.setExtraAdGroupMarkers(extraAdGroupTimesMs, extraPlayedAdGroups); + } + + /** + * Set the {@link AspectRatioFrameLayout.AspectRatioListener}. + * + * @param listener The listener to be notified about aspect ratios changes of the video content or + * the content frame. + */ + public void setAspectRatioListener( + @Nullable AspectRatioFrameLayout.AspectRatioListener listener) { + Assertions.checkStateNotNull(contentFrame); + contentFrame.setAspectRatioListener(listener); + } + + /** + * Gets the view onto which video is rendered. This is a: + * + *

+ * + * @return The {@link SurfaceView}, {@link TextureView}, {@link SphericalGLSurfaceView}, {@link + * VideoDecoderGLSurfaceView} or {@code null}. + */ + @Nullable + public View getVideoSurfaceView() { + return surfaceView; + } + + /** + * Gets the overlay {@link FrameLayout}, which can be populated with UI elements to show on top of + * the player. + * + * @return The overlay {@link FrameLayout}, or {@code null} if the layout has been customized and + * the overlay is not present. + */ + @Nullable + public FrameLayout getOverlayFrameLayout() { + return overlayFrameLayout; + } + + /** + * Gets the {@link SubtitleView}. + * + * @return The {@link SubtitleView}, or {@code null} if the layout has been customized and the + * subtitle view is not present. + */ + @Nullable + public SubtitleView getSubtitleView() { + return subtitleView; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!useController() || player == null) { + return false; + } + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + isTouching = true; + return true; + case MotionEvent.ACTION_UP: + if (isTouching) { + isTouching = false; + return performClick(); + } + return false; + default: + return false; + } + } + + @Override + public boolean performClick() { + super.performClick(); + return toggleControllerVisibility(); + } + + @Override + public boolean onTrackballEvent(MotionEvent ev) { + if (!useController() || player == null) { + return false; + } + maybeShowController(true); + return true; + } + + /** + * Should be called when the player is visible to the user and if {@code surface_type} is {@code + * spherical_gl_surface_view}. It is the counterpart to {@link #onPause()}. + * + *

This method should typically be called in {@code Activity.onStart()}, or {@code + * Activity.onResume()} for API versions <= 23. + */ + public void onResume() { + if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).onResume(); + } + } + + /** + * Should be called when the player is no longer visible to the user and if {@code surface_type} + * is {@code spherical_gl_surface_view}. It is the counterpart to {@link #onResume()}. + * + *

This method should typically be called in {@code Activity.onStop()}, or {@code + * Activity.onPause()} for API versions <= 23. + */ + public void onPause() { + if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).onPause(); + } + } + + /** + * Called when there's a change in the aspect ratio of the content being displayed. The default + * implementation sets the aspect ratio of the content frame to that of the content, unless the + * content view is a {@link SphericalGLSurfaceView} in which case the frame's aspect ratio is + * cleared. + * + * @param contentAspectRatio The aspect ratio of the content. + * @param contentFrame The content frame, or {@code null}. + * @param contentView The view that holds the content being displayed, or {@code null}. + */ + protected void onContentAspectRatioChanged( + float contentAspectRatio, + @Nullable AspectRatioFrameLayout contentFrame, + @Nullable View contentView) { + if (contentFrame != null) { + contentFrame.setAspectRatio( + contentView instanceof SphericalGLSurfaceView ? 0 : contentAspectRatio); + } + } + + // AdsLoader.AdViewProvider implementation. + + @Override + public ViewGroup getAdViewGroup() { + return Assertions.checkStateNotNull( + adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback"); + } + + @Override + public View[] getAdOverlayViews() { + ArrayList overlayViews = new ArrayList<>(); + if (overlayFrameLayout != null) { + overlayViews.add(overlayFrameLayout); + } + if (controller != null) { + overlayViews.add(controller); + } + return overlayViews.toArray(new View[0]); + } + + // Internal methods. + + @EnsuresNonNullIf(expression = "controller", result = true) + private boolean useController() { + if (useController) { + Assertions.checkStateNotNull(controller); + return true; + } + return false; + } + + @EnsuresNonNullIf(expression = "artworkView", result = true) + private boolean useArtwork() { + if (useArtwork) { + Assertions.checkStateNotNull(artworkView); + return true; + } + return false; + } + + private boolean toggleControllerVisibility() { + if (!useController() || player == null) { + return false; + } + if (!controller.isFullyVisible()) { + maybeShowController(true); + return true; + } else if (controllerHideOnTouch) { + controller.hide(); + return true; + } + return false; + } + + /** Shows the playback controls, but only if forced or shown indefinitely. */ + private void maybeShowController(boolean isForced) { + if (isPlayingAd() && controllerHideDuringAds) { + return; + } + if (useController()) { + boolean wasShowingIndefinitely = + controller.isFullyVisible() && controller.getShowTimeoutMs() <= 0; + boolean shouldShowIndefinitely = shouldShowControllerIndefinitely(); + if (isForced || wasShowingIndefinitely || shouldShowIndefinitely) { + showController(shouldShowIndefinitely); + } + } + } + + private boolean shouldShowControllerIndefinitely() { + if (player == null) { + return true; + } + int playbackState = player.getPlaybackState(); + return controllerAutoShow + && (playbackState == Player.STATE_IDLE + || playbackState == Player.STATE_ENDED + || !player.getPlayWhenReady()); + } + + private void showController(boolean showIndefinitely) { + if (!useController()) { + return; + } + controller.setShowTimeoutMs(showIndefinitely ? 0 : controllerShowTimeoutMs); + controller.show(); + } + + private boolean isPlayingAd() { + return player != null && player.isPlayingAd() && player.getPlayWhenReady(); + } + + private void updateForCurrentTrackSelections(boolean isNewPlayer) { + @Nullable Player player = this.player; + if (player == null || player.getCurrentTrackGroups().isEmpty()) { + if (!keepContentOnPlayerReset) { + hideArtwork(); + closeShutter(); + } + return; + } + + if (isNewPlayer && !keepContentOnPlayerReset) { + // Hide any video from the previous player. + closeShutter(); + } + + TrackSelectionArray selections = player.getCurrentTrackSelections(); + for (int i = 0; i < selections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_VIDEO && selections.get(i) != null) { + // Video enabled so artwork must be hidden. If the shutter is closed, it will be opened in + // onRenderedFirstFrame(). + hideArtwork(); + return; + } + } + + // Video disabled so the shutter must be closed. + closeShutter(); + // Display artwork if enabled and available, else hide it. + if (useArtwork()) { + for (int i = 0; i < selections.length; i++) { + @Nullable TrackSelection selection = selections.get(i); + if (selection != null) { + for (int j = 0; j < selection.length(); j++) { + @Nullable Metadata metadata = selection.getFormat(j).metadata; + if (metadata != null && setArtworkFromMetadata(metadata)) { + return; + } + } + } + } + if (setDrawableArtwork(defaultArtwork)) { + return; + } + } + // Artwork disabled or unavailable. + hideArtwork(); + } + + @RequiresNonNull("artworkView") + private boolean setArtworkFromMetadata(Metadata metadata) { + boolean isArtworkSet = false; + int currentPictureType = PICTURE_TYPE_NOT_SET; + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry metadataEntry = metadata.get(i); + int pictureType; + byte[] bitmapData; + if (metadataEntry instanceof ApicFrame) { + bitmapData = ((ApicFrame) metadataEntry).pictureData; + pictureType = ((ApicFrame) metadataEntry).pictureType; + } else if (metadataEntry instanceof PictureFrame) { + bitmapData = ((PictureFrame) metadataEntry).pictureData; + pictureType = ((PictureFrame) metadataEntry).pictureType; + } else { + continue; + } + // Prefer the first front cover picture. If there aren't any, prefer the first picture. + if (currentPictureType == PICTURE_TYPE_NOT_SET || pictureType == PICTURE_TYPE_FRONT_COVER) { + Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length); + isArtworkSet = setDrawableArtwork(new BitmapDrawable(getResources(), bitmap)); + currentPictureType = pictureType; + if (currentPictureType == PICTURE_TYPE_FRONT_COVER) { + break; + } + } + } + return isArtworkSet; + } + + @RequiresNonNull("artworkView") + private boolean setDrawableArtwork(@Nullable Drawable drawable) { + if (drawable != null) { + int drawableWidth = drawable.getIntrinsicWidth(); + int drawableHeight = drawable.getIntrinsicHeight(); + if (drawableWidth > 0 && drawableHeight > 0) { + float artworkAspectRatio = (float) drawableWidth / drawableHeight; + onContentAspectRatioChanged(artworkAspectRatio, contentFrame, artworkView); + artworkView.setImageDrawable(drawable); + artworkView.setVisibility(VISIBLE); + return true; + } + } + return false; + } + + private void hideArtwork() { + if (artworkView != null) { + artworkView.setImageResource(android.R.color.transparent); // Clears any bitmap reference. + artworkView.setVisibility(INVISIBLE); + } + } + + private void closeShutter() { + if (shutterView != null) { + shutterView.setVisibility(View.VISIBLE); + } + } + + private void updateBuffering() { + if (bufferingView != null) { + boolean showBufferingSpinner = + player != null + && player.getPlaybackState() == Player.STATE_BUFFERING + && (showBuffering == SHOW_BUFFERING_ALWAYS + || (showBuffering == SHOW_BUFFERING_WHEN_PLAYING && player.getPlayWhenReady())); + bufferingView.setVisibility(showBufferingSpinner ? View.VISIBLE : View.GONE); + } + } + + private void updateErrorMessage() { + if (errorMessageView != null) { + if (customErrorMessage != null) { + errorMessageView.setText(customErrorMessage); + errorMessageView.setVisibility(View.VISIBLE); + return; + } + @Nullable ExoPlaybackException error = player != null ? player.getPlayerError() : null; + if (error != null && errorMessageProvider != null) { + CharSequence errorMessage = errorMessageProvider.getErrorMessage(error).second; + errorMessageView.setText(errorMessage); + errorMessageView.setVisibility(View.VISIBLE); + } else { + errorMessageView.setVisibility(View.GONE); + } + } + } + + private void updateContentDescription() { + if (controller == null || !useController) { + setContentDescription(/* contentDescription= */ null); + } else if (controller.isFullyVisible()) { + setContentDescription( + /* contentDescription= */ controllerHideOnTouch + ? getResources().getString(R.string.exo_controls_hide) + : null); + } else { + setContentDescription( + /* contentDescription= */ getResources().getString(R.string.exo_controls_show)); + } + } + + private void updateControllerVisibility() { + if (isPlayingAd() && controllerHideDuringAds) { + hideController(); + } else { + maybeShowController(false); + } + } + + @RequiresApi(23) + private static void configureEditModeLogoV23(Resources resources, ImageView logo) { + logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo, null)); + logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color, null)); + } + + private static void configureEditModeLogo(Resources resources, ImageView logo) { + logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo)); + logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color)); + } + + @SuppressWarnings("ResourceType") + private static void setResizeModeRaw(AspectRatioFrameLayout aspectRatioFrame, int resizeMode) { + aspectRatioFrame.setResizeMode(resizeMode); + } + + /** Applies a texture rotation to a {@link TextureView}. */ + private static void applyTextureViewRotation(TextureView textureView, int textureViewRotation) { + Matrix transformMatrix = new Matrix(); + float textureViewWidth = textureView.getWidth(); + float textureViewHeight = textureView.getHeight(); + if (textureViewWidth != 0 && textureViewHeight != 0 && textureViewRotation != 0) { + float pivotX = textureViewWidth / 2; + float pivotY = textureViewHeight / 2; + transformMatrix.postRotate(textureViewRotation, pivotX, pivotY); + + // After rotation, scale the rotated texture to fit the TextureView size. + RectF originalTextureRect = new RectF(0, 0, textureViewWidth, textureViewHeight); + RectF rotatedTextureRect = new RectF(); + transformMatrix.mapRect(rotatedTextureRect, originalTextureRect); + transformMatrix.postScale( + textureViewWidth / rotatedTextureRect.width(), + textureViewHeight / rotatedTextureRect.height(), + pivotX, + pivotY); + } + textureView.setTransform(transformMatrix); + } + + @SuppressLint("InlinedApi") + private boolean isDpadKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_UP_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_UP_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_CENTER; + } + + private final class ComponentListener + implements Player.EventListener, + TextOutput, + VideoListener, + OnLayoutChangeListener, + SingleTapListener, + StyledPlayerControlView.VisibilityListener { + + private final Period period; + private @Nullable Object lastPeriodUidWithTracks; + + public ComponentListener() { + period = new Period(); + } + + // TextOutput implementation + + @Override + public void onCues(List cues) { + if (subtitleView != null) { + subtitleView.onCues(cues); + } + } + + // VideoListener implementation + + @Override + public void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + float videoAspectRatio = + (height == 0 || width == 0) ? 1 : (width * pixelWidthHeightRatio) / height; + + if (surfaceView instanceof TextureView) { + // Try to apply rotation transformation when our surface is a TextureView. + if (unappliedRotationDegrees == 90 || unappliedRotationDegrees == 270) { + // We will apply a rotation 90/270 degree to the output texture of the TextureView. + // In this case, the output video's width and height will be swapped. + videoAspectRatio = 1 / videoAspectRatio; + } + if (textureViewRotation != 0) { + surfaceView.removeOnLayoutChangeListener(this); + } + textureViewRotation = unappliedRotationDegrees; + if (textureViewRotation != 0) { + // The texture view's dimensions might be changed after layout step. + // So add an OnLayoutChangeListener to apply rotation after layout step. + surfaceView.addOnLayoutChangeListener(this); + } + applyTextureViewRotation((TextureView) surfaceView, textureViewRotation); + } + + onContentAspectRatioChanged(videoAspectRatio, contentFrame, surfaceView); + } + + @Override + public void onRenderedFirstFrame() { + if (shutterView != null) { + shutterView.setVisibility(INVISIBLE); + } + } + + @Override + public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Suppress the update if transitioning to an unprepared period within the same window. This + // is necessary to avoid closing the shutter when such a transition occurs. See: + // https://github.com/google/ExoPlayer/issues/5507. + Player player = Assertions.checkNotNull(StyledPlayerView.this.player); + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + lastPeriodUidWithTracks = null; + } else if (!player.getCurrentTrackGroups().isEmpty()) { + lastPeriodUidWithTracks = + timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid; + } else if (lastPeriodUidWithTracks != null) { + int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks); + if (lastPeriodIndexWithTracks != C.INDEX_UNSET) { + int lastWindowIndexWithTracks = + timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex; + if (player.getCurrentWindowIndex() == lastWindowIndexWithTracks) { + // We're in the same window. Suppress the update. + return; + } + } + lastPeriodUidWithTracks = null; + } + + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + + // Player.EventListener implementation + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + updateBuffering(); + updateErrorMessage(); + updateControllerVisibility(); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + updateBuffering(); + updateControllerVisibility(); + } + + @Override + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + if (isPlayingAd() && controllerHideDuringAds) { + hideController(); + } + } + + // OnLayoutChangeListener implementation + + @Override + public void onLayoutChange( + View view, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + applyTextureViewRotation((TextureView) view, textureViewRotation); + } + + // SingleTapListener implementation + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return toggleControllerVisibility(); + } + + // StyledPlayerControlView.VisibilityListener implementation + + @Override + public void onVisibilityChange(int visibility) { + updateContentDescription(); + } + } +} diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml new file mode 100644 index 0000000000..5e4dd5550f --- /dev/null +++ b/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml b/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml new file mode 100644 index 0000000000..ee43206b4a --- /dev/null +++ b/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_audiotrack.xml b/library/ui/src/main/res/drawable/exo_ic_audiotrack.xml new file mode 100644 index 0000000000..7ee298e357 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_audiotrack.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_check.xml b/library/ui/src/main/res/drawable/exo_ic_check.xml new file mode 100644 index 0000000000..ad5d63ac5c --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_check.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_chevron_left.xml b/library/ui/src/main/res/drawable/exo_ic_chevron_left.xml new file mode 100644 index 0000000000..d614a9e2f2 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_chevron_left.xml @@ -0,0 +1,24 @@ + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_chevron_right.xml b/library/ui/src/main/res/drawable/exo_ic_chevron_right.xml new file mode 100644 index 0000000000..9b25426dd1 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_chevron_right.xml @@ -0,0 +1,24 @@ + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_default_album_image.xml b/library/ui/src/main/res/drawable/exo_ic_default_album_image.xml new file mode 100644 index 0000000000..d95f42ab3d --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_default_album_image.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_forward.xml b/library/ui/src/main/res/drawable/exo_ic_forward.xml new file mode 100644 index 0000000000..11470d4d4a --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_forward.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_forward_30.xml b/library/ui/src/main/res/drawable/exo_ic_forward_30.xml new file mode 100644 index 0000000000..b55831ec57 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_forward_30.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml b/library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml new file mode 100644 index 0000000000..f0faf4d025 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_fullscreen_enter.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml b/library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml new file mode 100644 index 0000000000..73d35277a3 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_fullscreen_exit.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_launch.xml b/library/ui/src/main/res/drawable/exo_ic_launch.xml new file mode 100644 index 0000000000..1646f2a0e4 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_launch.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml b/library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml new file mode 100644 index 0000000000..6789374094 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_pause_circle_filled.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml b/library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml new file mode 100644 index 0000000000..f00f85f543 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_play_circle_filled.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml b/library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml new file mode 100644 index 0000000000..e57acb5db6 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_replay_circle_filled.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_rewind.xml b/library/ui/src/main/res/drawable/exo_ic_rewind.xml new file mode 100644 index 0000000000..2c741f7224 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_rewind.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_rewind_10.xml b/library/ui/src/main/res/drawable/exo_ic_rewind_10.xml new file mode 100644 index 0000000000..942fe5b76f --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_rewind_10.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_settings.xml b/library/ui/src/main/res/drawable/exo_ic_settings.xml new file mode 100644 index 0000000000..2dab2c0f17 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_settings.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_skip_next.xml b/library/ui/src/main/res/drawable/exo_ic_skip_next.xml new file mode 100644 index 0000000000..183434e864 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_skip_next.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_skip_previous.xml b/library/ui/src/main/res/drawable/exo_ic_skip_previous.xml new file mode 100644 index 0000000000..363b94f3dc --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_skip_previous.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_speed.xml b/library/ui/src/main/res/drawable/exo_ic_speed.xml new file mode 100644 index 0000000000..fd1fd8e1d5 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_speed.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml b/library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml new file mode 100644 index 0000000000..ea6819eb3a --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_subtitle_off.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml b/library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml new file mode 100644 index 0000000000..b1d36cde79 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ic_subtitle_on.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_progress.xml b/library/ui/src/main/res/drawable/exo_progress.xml new file mode 100644 index 0000000000..2ba05326f0 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_progress.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_progress_thumb.xml b/library/ui/src/main/res/drawable/exo_progress_thumb.xml new file mode 100644 index 0000000000..e61a015f7d --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_progress_thumb.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml new file mode 100644 index 0000000000..9f7e1fd027 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_rew.xml b/library/ui/src/main/res/drawable/exo_ripple_rew.xml new file mode 100644 index 0000000000..5562b1352c --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ripple_rew.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_title_bar_gradient.xml b/library/ui/src/main/res/drawable/exo_title_bar_gradient.xml new file mode 100644 index 0000000000..fd3b0745fe --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_title_bar_gradient.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/library/ui/src/main/res/font/roboto_medium_numbers.ttf b/library/ui/src/main/res/font/roboto_medium_numbers.ttf new file mode 100644 index 0000000000..b61ac79ddf Binary files /dev/null and b/library/ui/src/main/res/font/roboto_medium_numbers.ttf differ diff --git a/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml b/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml new file mode 100644 index 0000000000..dafe7d9585 --- /dev/null +++ b/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml @@ -0,0 +1,28 @@ + + + + + +