NewPipe/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java

1592 lines
65 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package org.schabi.newpipe.player.ui;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
import static org.schabi.newpipe.player.Player.RENDERER_UNAVAILABLE;
import static org.schabi.newpipe.player.Player.STATE_BUFFERING;
import static org.schabi.newpipe.player.Player.STATE_COMPLETED;
import static org.schabi.newpipe.player.Player.STATE_PAUSED;
import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK;
import static org.schabi.newpipe.player.Player.STATE_PLAYING;
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.GestureDetector;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import android.widget.SeekBar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.view.ContextThemeWrapper;
import androidx.appcompat.widget.PopupMenu;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.Tracks;
import com.google.android.exoplayer2.text.Cue;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.CaptionStyleCompat;
import com.google.android.exoplayer2.video.VideoSize;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.gesture.BasePlayerGestureListener;
import org.schabi.newpipe.player.gesture.DisplayPortion;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
import org.schabi.newpipe.player.playback.SurfaceHolderCallback;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public abstract class VideoPlayerUi extends PlayerUi
implements SeekBar.OnSeekBarChangeListener, View.OnClickListener, View.OnLongClickListener,
PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
private static final String TAG = VideoPlayerUi.class.getSimpleName();
// time constants
public static final long DEFAULT_CONTROLS_DURATION = 300; // 300 millis
public static final long DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds
public static final long DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds
public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis
// other constants (TODO remove playback speeds and use normal menu for popup, too)
private static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f};
/*//////////////////////////////////////////////////////////////////////////
// Views
//////////////////////////////////////////////////////////////////////////*/
protected PlayerBinding binding;
private final Handler controlsVisibilityHandler = new Handler(Looper.getMainLooper());
@Nullable private SurfaceHolderCallback surfaceHolderCallback;
boolean surfaceIsSetup = false;
@Nullable private Bitmap thumbnail = null;
/*//////////////////////////////////////////////////////////////////////////
// Popup menus ("popup" means that they pop up, not that they belong to the popup player)
//////////////////////////////////////////////////////////////////////////*/
private static final int POPUP_MENU_ID_QUALITY = 69;
private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79;
private static final int POPUP_MENU_ID_CAPTION = 89;
protected boolean isSomePopupMenuVisible = false;
private PopupMenu qualityPopupMenu;
protected PopupMenu playbackSpeedPopupMenu;
private PopupMenu captionPopupMenu;
/*//////////////////////////////////////////////////////////////////////////
// Gestures
//////////////////////////////////////////////////////////////////////////*/
private GestureDetector gestureDetector;
private BasePlayerGestureListener playerGestureListener;
@Nullable private View.OnLayoutChangeListener onLayoutChangeListener = null;
@NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder =
new SeekbarPreviewThumbnailHolder();
/*//////////////////////////////////////////////////////////////////////////
// Constructor, setup, destroy
//////////////////////////////////////////////////////////////////////////*/
//region Constructor, setup, destroy
protected VideoPlayerUi(@NonNull final Player player,
@NonNull final PlayerBinding playerBinding) {
super(player);
binding = playerBinding;
setupFromView();
}
public void setupFromView() {
initViews();
initListeners();
setupPlayerSeekOverlay();
}
private void initViews() {
setupSubtitleView();
binding.resizeTextView
.setText(PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode()));
binding.playbackSeekBar.getThumb()
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
binding.playbackSeekBar.getProgressDrawable()
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY));
final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context,
R.style.DarkPopupMenu);
qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView);
playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed);
captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView);
binding.progressBarLoadingPanel.getIndeterminateDrawable()
.setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY));
binding.titleTextView.setSelected(true);
binding.channelTextView.setSelected(true);
// Prevent hiding of bottom sheet via swipe inside queue
binding.itemsList.setNestedScrollingEnabled(false);
}
abstract BasePlayerGestureListener buildGestureListener();
protected void initListeners() {
binding.qualityTextView.setOnClickListener(this);
binding.playbackSpeed.setOnClickListener(this);
binding.playbackSeekBar.setOnSeekBarChangeListener(this);
binding.captionTextView.setOnClickListener(this);
binding.resizeTextView.setOnClickListener(this);
binding.playbackLiveSync.setOnClickListener(this);
playerGestureListener = buildGestureListener();
gestureDetector = new GestureDetector(context, playerGestureListener);
binding.getRoot().setOnTouchListener(playerGestureListener);
binding.repeatButton.setOnClickListener(v -> onRepeatClicked());
binding.shuffleButton.setOnClickListener(v -> onShuffleClicked());
binding.playPauseButton.setOnClickListener(this);
binding.playPreviousButton.setOnClickListener(this);
binding.playNextButton.setOnClickListener(this);
binding.moreOptionsButton.setOnClickListener(this);
binding.moreOptionsButton.setOnLongClickListener(this);
binding.share.setOnClickListener(this);
binding.share.setOnLongClickListener(this);
binding.fullScreenButton.setOnClickListener(this);
binding.screenRotationButton.setOnClickListener(this);
binding.playWithKodi.setOnClickListener(this);
binding.openInBrowser.setOnClickListener(this);
binding.playerCloseButton.setOnClickListener(this);
binding.switchMute.setOnClickListener(this);
ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> {
final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout());
if (!cutout.equals(Insets.NONE)) {
view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom);
}
return windowInsets;
});
// PlaybackControlRoot already consumed window insets but we should pass them to
// player_overlays and fast_seek_overlay too. Without it they will be off-centered.
onLayoutChangeListener
= (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
binding.playerOverlays.setPadding(
v.getPaddingLeft(),
v.getPaddingTop(),
v.getPaddingRight(),
v.getPaddingBottom());
// If we added padding to the fast seek overlay, too, it would not go under the
// system ui. Instead we apply negative margins equal to the window insets of
// the opposite side, so that the view covers all of the player (overflowing on
// some sides) and its center coincides with the center of other controls.
final RelativeLayout.LayoutParams fastSeekParams = (RelativeLayout.LayoutParams)
binding.fastSeekOverlay.getLayoutParams();
fastSeekParams.leftMargin = -v.getPaddingRight();
fastSeekParams.topMargin = -v.getPaddingBottom();
fastSeekParams.rightMargin = -v.getPaddingLeft();
fastSeekParams.bottomMargin = -v.getPaddingTop();
};
binding.playbackControlRoot.addOnLayoutChangeListener(onLayoutChangeListener);
}
protected void deinitListeners() {
binding.qualityTextView.setOnClickListener(null);
binding.playbackSpeed.setOnClickListener(null);
binding.playbackSeekBar.setOnSeekBarChangeListener(null);
binding.captionTextView.setOnClickListener(null);
binding.resizeTextView.setOnClickListener(null);
binding.playbackLiveSync.setOnClickListener(null);
binding.getRoot().setOnTouchListener(null);
playerGestureListener = null;
gestureDetector = null;
binding.repeatButton.setOnClickListener(null);
binding.shuffleButton.setOnClickListener(null);
binding.playPauseButton.setOnClickListener(null);
binding.playPreviousButton.setOnClickListener(null);
binding.playNextButton.setOnClickListener(null);
binding.moreOptionsButton.setOnClickListener(null);
binding.moreOptionsButton.setOnLongClickListener(null);
binding.share.setOnClickListener(null);
binding.share.setOnLongClickListener(null);
binding.fullScreenButton.setOnClickListener(null);
binding.screenRotationButton.setOnClickListener(null);
binding.playWithKodi.setOnClickListener(null);
binding.openInBrowser.setOnClickListener(null);
binding.playerCloseButton.setOnClickListener(null);
binding.switchMute.setOnClickListener(null);
ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, null);
binding.playbackControlRoot.removeOnLayoutChangeListener(onLayoutChangeListener);
}
/**
* Initializes the Fast-For/Backward overlay.
*/
private void setupPlayerSeekOverlay() {
binding.fastSeekOverlay
.seekSecondsSupplier(() -> retrieveSeekDurationFromPreferences(player) / 1000)
.performListener(new PlayerFastSeekOverlay.PerformListener() {
@Override
public void onDoubleTap() {
animate(binding.fastSeekOverlay, true, SEEK_OVERLAY_DURATION);
}
@Override
public void onDoubleTapEnd() {
animate(binding.fastSeekOverlay, false, SEEK_OVERLAY_DURATION);
}
@NonNull
@Override
public FastSeekDirection getFastSeekDirection(
@NonNull final DisplayPortion portion
) {
if (player.exoPlayerIsNull()) {
// Abort seeking
playerGestureListener.endMultiDoubleTap();
return FastSeekDirection.NONE;
}
if (portion == DisplayPortion.LEFT) {
// Check if it's possible to rewind
// Small puffer to eliminate infinite rewind seeking
if (player.getExoPlayer().getCurrentPosition() < 500L) {
return FastSeekDirection.NONE;
}
return FastSeekDirection.BACKWARD;
} else if (portion == DisplayPortion.RIGHT) {
// Check if it's possible to fast-forward
if (player.getCurrentState() == STATE_COMPLETED
|| player.getExoPlayer().getCurrentPosition()
>= player.getExoPlayer().getDuration()) {
return FastSeekDirection.NONE;
}
return FastSeekDirection.FORWARD;
}
/* portion == DisplayPortion.MIDDLE */
return FastSeekDirection.NONE;
}
@Override
public void seek(final boolean forward) {
playerGestureListener.keepInDoubleTapMode();
if (forward) {
player.fastForward();
} else {
player.fastRewind();
}
}
});
playerGestureListener.doubleTapControls(binding.fastSeekOverlay);
}
public void deinitPlayerSeekOverlay() {
binding.fastSeekOverlay
.seekSecondsSupplier(null)
.performListener(null);
}
@Override
public void setupAfterIntent() {
super.setupAfterIntent();
setupElementsVisibility();
setupElementsSize(context.getResources());
binding.getRoot().setVisibility(View.VISIBLE);
binding.playPauseButton.requestFocus();
}
@Override
public void initPlayer() {
super.initPlayer();
setupVideoSurfaceIfNeeded();
}
@Override
public void initPlayback() {
super.initPlayback();
// #6825 - Ensure that the shuffle-button is in the correct state on the UI
setShuffleButton(player.getExoPlayer().getShuffleModeEnabled());
}
public abstract void removeViewFromParent();
@Override
public void destroyPlayer() {
super.destroyPlayer();
clearVideoSurface();
}
@Override
public void destroy() {
super.destroy();
if (binding != null) {
binding.endScreen.setImageBitmap(null);
}
deinitPlayerSeekOverlay();
deinitListeners();
}
protected void setupElementsVisibility() {
setMuteButton(player.isMuted());
animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, 0);
}
protected abstract void setupElementsSize(Resources resources);
protected void setupElementsSize(final int buttonsMinWidth,
final int playerTopPad,
final int controlsPad,
final int buttonsPad) {
binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0);
binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0);
binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
binding.playbackSpeed.setMinimumWidth(buttonsMinWidth);
binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Broadcast receiver
//////////////////////////////////////////////////////////////////////////*/
//region Broadcast receiver
@Override
public void onBroadcastReceived(final Intent intent) {
super.onBroadcastReceived(intent);
if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) {
// When the orientation changed, the screen height might be smaller.
// If the end screen thumbnail is not re-scaled,
// it can be larger than the current screen height
// and thus enlarging the whole player.
// This causes the seekbar to be ouf the visible area.
updateEndScreenThumbnail();
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Thumbnail
//////////////////////////////////////////////////////////////////////////*/
//region Thumbnail
/**
* Scale the player audio / end screen thumbnail down if necessary.
* <p>
* This is necessary when the thumbnail's height is larger than the device's height
* and thus is enlarging the player's height
* causing the bottom playback controls to be out of the visible screen.
* </p>
*/
@Override
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
super.onThumbnailLoaded(bitmap);
thumbnail = bitmap;
updateEndScreenThumbnail();
}
private void updateEndScreenThumbnail() {
if (thumbnail == null) {
// remove end screen thumbnail
binding.endScreen.setImageDrawable(null);
return;
}
final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail);
final Bitmap endScreenBitmap = Bitmap.createScaledBitmap(
thumbnail,
(int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)),
(int) endScreenHeight,
true);
if (DEBUG) {
Log.d(TAG, "Thumbnail - onThumbnailLoaded() called with: "
+ "currentThumbnail = [" + thumbnail + "], "
+ thumbnail.getWidth() + "x" + thumbnail.getHeight()
+ ", scaled end screen height = " + endScreenHeight
+ ", scaled end screen width = " + endScreenBitmap.getWidth());
}
binding.endScreen.setImageBitmap(endScreenBitmap);
}
protected abstract float calculateMaxEndScreenThumbnailHeight(@NonNull Bitmap bitmap);
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Progress loop and updates
//////////////////////////////////////////////////////////////////////////*/
//region Progress loop and updates
@Override
public void onUpdateProgress(final int currentProgress,
final int duration,
final int bufferPercent) {
if (duration != binding.playbackSeekBar.getMax()) {
setVideoDurationToControls(duration);
}
if (player.getCurrentState() != STATE_PAUSED) {
updatePlayBackElementsCurrentDuration(currentProgress);
}
if (player.isLoading() || bufferPercent > 90) {
binding.playbackSeekBar.setSecondaryProgress(
(int) (binding.playbackSeekBar.getMax() * ((float) bufferPercent / 100)));
}
if (DEBUG && bufferPercent % 20 == 0) { //Limit log
Log.d(TAG, "notifyProgressUpdateToListeners() called with: "
+ "isVisible = " + isControlsVisible() + ", "
+ "currentProgress = [" + currentProgress + "], "
+ "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]");
}
binding.playbackLiveSync.setClickable(!player.isLiveEdge());
}
/**
* Sets the current duration into the corresponding elements.
* @param currentProgress the current progress, in milliseconds
*/
private void updatePlayBackElementsCurrentDuration(final int currentProgress) {
// Don't set seekbar progress while user is seeking
if (player.getCurrentState() != STATE_PAUSED_SEEK) {
binding.playbackSeekBar.setProgress(currentProgress);
}
binding.playbackCurrentTime.setText(getTimeString(currentProgress));
}
/**
* Sets the video duration time into all control components (e.g. seekbar).
* @param duration the video duration, in milliseconds
*/
private void setVideoDurationToControls(final int duration) {
binding.playbackEndTime.setText(getTimeString(duration));
binding.playbackSeekBar.setMax(duration);
// This is important for Android TVs otherwise it would apply the default from
// setMax/Min methods which is (max - min) / 20
binding.playbackSeekBar.setKeyProgressIncrement(
PlayerHelper.retrieveSeekDurationFromPreferences(player));
}
@Override // seekbar listener
public void onProgressChanged(final SeekBar seekBar, final int progress,
final boolean fromUser) {
// Currently we don't need method execution when fromUser is false
if (!fromUser) {
return;
}
if (DEBUG) {
Log.d(TAG, "onProgressChanged() called with: "
+ "seekBar = [" + seekBar + "], progress = [" + progress + "]");
}
binding.currentDisplaySeek.setText(getTimeString(progress));
// Seekbar Preview Thumbnail
SeekbarPreviewThumbnailHelper
.tryResizeAndSetSeekbarPreviewThumbnail(
player.getContext(),
seekbarPreviewThumbnailHolder.getBitmapAt(progress),
binding.currentSeekbarPreviewThumbnail,
binding.subtitleView::getWidth);
adjustSeekbarPreviewContainer();
}
private void adjustSeekbarPreviewContainer() {
try {
// Should only be required when an error occurred before
// and the layout was positioned in the center
binding.bottomSeekbarPreviewLayout.setGravity(Gravity.NO_GRAVITY);
// Calculate the current left position of seekbar progress in px
// More info: https://stackoverflow.com/q/20493577
final int currentSeekbarLeft =
binding.playbackSeekBar.getLeft()
+ binding.playbackSeekBar.getPaddingLeft()
+ binding.playbackSeekBar.getThumb().getBounds().left;
// Calculate the (unchecked) left position of the container
final int uncheckedContainerLeft =
currentSeekbarLeft - (binding.seekbarPreviewContainer.getWidth() / 2);
// Fix the position so it's within the boundaries
final int checkedContainerLeft =
Math.max(
Math.min(
uncheckedContainerLeft,
// Max left
binding.playbackWindowRoot.getWidth()
- binding.seekbarPreviewContainer.getWidth()
),
0 // Min left
);
// See also: https://stackoverflow.com/a/23249734
final LinearLayout.LayoutParams params =
new LinearLayout.LayoutParams(
binding.seekbarPreviewContainer.getLayoutParams());
params.setMarginStart(checkedContainerLeft);
binding.seekbarPreviewContainer.setLayoutParams(params);
} catch (final Exception ex) {
Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex);
// Fallback - position in the middle
binding.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER);
}
}
@Override // seekbar listener
public void onStartTrackingTouch(final SeekBar seekBar) {
if (DEBUG) {
Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]");
}
if (player.getCurrentState() != STATE_PAUSED_SEEK) {
player.changeState(STATE_PAUSED_SEEK);
}
player.saveWasPlaying();
if (player.isPlaying()) {
player.getExoPlayer().pause();
}
showControls(0);
animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION,
AnimationType.SCALE_AND_ALPHA);
animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION,
AnimationType.SCALE_AND_ALPHA);
}
@Override // seekbar listener
public void onStopTrackingTouch(final SeekBar seekBar) {
if (DEBUG) {
Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]");
}
player.seekTo(seekBar.getProgress());
if (player.wasPlaying() || player.getExoPlayer().getDuration() == seekBar.getProgress()) {
player.getExoPlayer().play();
}
binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress()));
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA);
if (player.getCurrentState() == STATE_PAUSED_SEEK) {
player.changeState(STATE_BUFFERING);
}
if (!player.isProgressLoopRunning()) {
player.startProgressLoop();
}
if (player.wasPlaying()) {
showControlsThenHide();
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Controls showing / hiding
//////////////////////////////////////////////////////////////////////////*/
//region Controls showing / hiding
public boolean isControlsVisible() {
return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE;
}
public void showControlsThenHide() {
if (DEBUG) {
Log.d(TAG, "showControlsThenHide() called");
}
showOrHideButtons();
showSystemUIPartially();
final long hideTime = binding.playbackControlRoot.isInTouchMode()
? DEFAULT_CONTROLS_HIDE_TIME
: DPAD_CONTROLS_HIDE_TIME;
showHideShadow(true, DEFAULT_CONTROLS_DURATION);
animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
AnimationType.ALPHA, 0, () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime));
}
public void showControls(final long duration) {
if (DEBUG) {
Log.d(TAG, "showControls() called");
}
showOrHideButtons();
showSystemUIPartially();
controlsVisibilityHandler.removeCallbacksAndMessages(null);
showHideShadow(true, duration);
animate(binding.playbackControlRoot, true, duration);
}
public void hideControls(final long duration, final long delay) {
if (DEBUG) {
Log.d(TAG, "hideControls() called with: duration = [" + duration
+ "], delay = [" + delay + "]");
}
showOrHideButtons();
controlsVisibilityHandler.removeCallbacksAndMessages(null);
controlsVisibilityHandler.postDelayed(() -> {
showHideShadow(false, duration);
animate(binding.playbackControlRoot, false, duration, AnimationType.ALPHA,
0, this::hideSystemUIIfNeeded);
}, delay);
}
public void showHideShadow(final boolean show, final long duration) {
animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null);
animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null);
animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null);
}
protected void showOrHideButtons() {
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue == null) {
return;
}
final boolean showPrev = playQueue.getIndex() != 0;
final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size();
binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE);
binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f);
binding.playNextButton.setVisibility(showNext ? View.VISIBLE : View.INVISIBLE);
binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f);
}
protected void showSystemUIPartially() {
// system UI is really changed only by MainPlayerUi, so overridden there
}
protected void hideSystemUIIfNeeded() {
// system UI is really changed only by MainPlayerUi, so overridden there
}
protected boolean isAnyListViewOpen() {
// only MainPlayerUi has list views for the queue and for segments, so overridden there
return false;
}
public boolean isFullscreen() {
// only MainPlayerUi can be in fullscreen, so overridden there
return false;
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Playback states
//////////////////////////////////////////////////////////////////////////*/
//region Playback states
@Override
public void onPrepared() {
super.onPrepared();
setVideoDurationToControls((int) player.getExoPlayer().getDuration());
binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed()));
}
@Override
public void onBlocked() {
super.onBlocked();
// if we are e.g. switching players, hide controls
hideControls(DEFAULT_CONTROLS_DURATION, 0);
binding.playbackSeekBar.setEnabled(false);
binding.playbackSeekBar.getThumb()
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
binding.loadingPanel.setBackgroundColor(Color.BLACK);
animate(binding.loadingPanel, true, 0);
animate(binding.surfaceForeground, true, 100);
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
animatePlayButtons(false, 100);
binding.getRoot().setKeepScreenOn(false);
}
@Override
public void onPlaying() {
super.onPlaying();
updateStreamRelatedViews();
binding.playbackSeekBar.setEnabled(true);
binding.playbackSeekBar.getThumb()
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
binding.loadingPanel.setVisibility(View.GONE);
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
() -> {
binding.playPauseButton.setImageResource(R.drawable.ic_pause);
animatePlayButtons(true, 200);
if (!isAnyListViewOpen()) {
binding.playPauseButton.requestFocus();
}
});
binding.getRoot().setKeepScreenOn(true);
}
@Override
public void onBuffering() {
super.onBuffering();
binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT);
binding.loadingPanel.setVisibility(View.VISIBLE);
binding.getRoot().setKeepScreenOn(true);
}
@Override
public void onPaused() {
super.onPaused();
// Don't let UI elements popup during double tap seeking. This state is entered sometimes
// during seeking/loading. This if-else check ensures that the controls aren't popping up.
if (!playerGestureListener.isDoubleTapping()) {
showControls(400);
binding.loadingPanel.setVisibility(View.GONE);
animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0,
() -> {
binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow);
animatePlayButtons(true, 200);
if (!isAnyListViewOpen()) {
binding.playPauseButton.requestFocus();
}
});
}
binding.getRoot().setKeepScreenOn(false);
}
@Override
public void onPausedSeek() {
super.onPausedSeek();
animatePlayButtons(false, 100);
binding.getRoot().setKeepScreenOn(true);
}
@Override
public void onCompleted() {
super.onCompleted();
animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0,
() -> {
binding.playPauseButton.setImageResource(R.drawable.ic_replay);
animatePlayButtons(true, DEFAULT_CONTROLS_DURATION);
});
binding.getRoot().setKeepScreenOn(false);
// When a (short) video ends the elements have to display the correct values - see #6180
updatePlayBackElementsCurrentDuration(binding.playbackSeekBar.getMax());
showControls(500);
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
binding.loadingPanel.setVisibility(View.GONE);
animate(binding.surfaceForeground, true, 100);
}
private void animatePlayButtons(final boolean show, final long duration) {
animate(binding.playPauseButton, show, duration, AnimationType.SCALE_AND_ALPHA);
@Nullable final PlayQueue playQueue = player.getPlayQueue();
if (playQueue == null) {
return;
}
if (!show || playQueue.getIndex() > 0) {
animate(
binding.playPreviousButton,
show,
duration,
AnimationType.SCALE_AND_ALPHA);
}
if (!show || playQueue.getIndex() + 1 < playQueue.getStreams().size()) {
animate(
binding.playNextButton,
show,
duration,
AnimationType.SCALE_AND_ALPHA);
}
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Repeat, shuffle, mute
//////////////////////////////////////////////////////////////////////////*/
//region Repeat, shuffle, mute
public void onRepeatClicked() {
if (DEBUG) {
Log.d(TAG, "onRepeatClicked() called");
}
player.cycleNextRepeatMode();
}
public void onShuffleClicked() {
if (DEBUG) {
Log.d(TAG, "onShuffleClicked() called");
}
player.toggleShuffleModeEnabled();
}
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
super.onRepeatModeChanged(repeatMode);
if (repeatMode == REPEAT_MODE_ALL) {
binding.repeatButton.setImageResource(R.drawable.exo_controls_repeat_all);
} else if (repeatMode == REPEAT_MODE_ONE) {
binding.repeatButton.setImageResource(R.drawable.exo_controls_repeat_one);
} else /* repeatMode == REPEAT_MODE_OFF */ {
binding.repeatButton.setImageResource(R.drawable.exo_controls_repeat_off);
}
}
@Override
public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
super.onShuffleModeEnabledChanged(shuffleModeEnabled);
setShuffleButton(shuffleModeEnabled);
}
@Override
public void onMuteUnmuteChanged(final boolean isMuted) {
super.onMuteUnmuteChanged(isMuted);
setMuteButton(isMuted);
}
private void setMuteButton(final boolean isMuted) {
binding.switchMute.setImageDrawable(AppCompatResources.getDrawable(context, isMuted
? R.drawable.ic_volume_off : R.drawable.ic_volume_up));
}
private void setShuffleButton(final boolean shuffled) {
binding.shuffleButton.setImageAlpha(shuffled ? 255 : 77);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Other player listeners
//////////////////////////////////////////////////////////////////////////*/
//region Other player listeners
@Override
public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) {
super.onPlaybackParametersChanged(playbackParameters);
binding.playbackSpeed.setText(formatSpeed(playbackParameters.speed));
}
@Override
public void onRenderedFirstFrame() {
super.onRenderedFirstFrame();
//TODO check if this causes black screen when switching to fullscreen
animate(binding.surfaceForeground, false, DEFAULT_CONTROLS_DURATION);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Metadata & stream related views
//////////////////////////////////////////////////////////////////////////*/
//region Metadata & stream related views
@Override
public void onMetadataChanged(@NonNull final StreamInfo info) {
super.onMetadataChanged(info);
updateStreamRelatedViews();
binding.titleTextView.setText(info.getName());
binding.channelTextView.setText(info.getUploaderName());
this.seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames());
}
private void updateStreamRelatedViews() {
//noinspection SimplifyOptionalCallChains
if (!player.getCurrentStreamInfo().isPresent()) {
return;
}
final StreamInfo info = player.getCurrentStreamInfo().get();
binding.qualityTextView.setVisibility(View.GONE);
binding.playbackSpeed.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.GONE);
switch (info.getStreamType()) {
case AUDIO_STREAM:
case POST_LIVE_AUDIO_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
case AUDIO_LIVE_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
case LIVE_STREAM:
binding.surfaceView.setVisibility(View.VISIBLE);
binding.endScreen.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
case VIDEO_STREAM:
case POST_LIVE_STREAM:
//noinspection SimplifyOptionalCallChains
if (player.getCurrentMetadata() != null
&& !player.getCurrentMetadata().getMaybeQuality().isPresent()
|| (info.getVideoStreams().isEmpty()
&& info.getVideoOnlyStreams().isEmpty())) {
break;
}
buildQualityMenu();
binding.qualityTextView.setVisibility(View.VISIBLE);
binding.surfaceView.setVisibility(View.VISIBLE);
// fallthrough
default:
binding.endScreen.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
}
buildPlaybackSpeedMenu();
binding.playbackSpeed.setVisibility(View.VISIBLE);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Popup menus ("popup" means that they pop up, not that they belong to the popup player)
//////////////////////////////////////////////////////////////////////////*/
//region Popup menus ("popup" means that they pop up, not that they belong to the popup player)
private void buildQualityMenu() {
if (qualityPopupMenu == null) {
return;
}
qualityPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_QUALITY);
@Nullable final List<VideoStream> availableStreams
= Optional.ofNullable(player.getCurrentMetadata())
.flatMap(MediaItemTag::getMaybeQuality)
.map(MediaItemTag.Quality::getSortedVideoStreams)
.orElse(null);
if (availableStreams == null) {
return;
}
for (int i = 0; i < availableStreams.size(); i++) {
final VideoStream videoStream = availableStreams.get(i);
qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat
.getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution());
}
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
if (selectedVideoStream != null) {
binding.qualityTextView.setText(selectedVideoStream.getResolution());
}
qualityPopupMenu.setOnMenuItemClickListener(this);
qualityPopupMenu.setOnDismissListener(this);
}
private void buildPlaybackSpeedMenu() {
if (playbackSpeedPopupMenu == null) {
return;
}
playbackSpeedPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED);
for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) {
playbackSpeedPopupMenu.getMenu().add(POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE,
formatSpeed(PLAYBACK_SPEEDS[i]));
}
binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed()));
playbackSpeedPopupMenu.setOnMenuItemClickListener(this);
playbackSpeedPopupMenu.setOnDismissListener(this);
}
private void buildCaptionMenu(@NonNull final List<String> availableLanguages) {
if (captionPopupMenu == null) {
return;
}
captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION);
captionPopupMenu.setOnDismissListener(this);
// Add option for turning off caption
final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION,
0, Menu.NONE, R.string.caption_none);
captionOffItem.setOnMenuItemClickListener(menuItem -> {
final int textRendererIndex = player.getCaptionRendererIndex();
if (textRendererIndex != RENDERER_UNAVAILABLE) {
player.getTrackSelector().setParameters(player.getTrackSelector()
.buildUponParameters().setRendererDisabled(textRendererIndex, true));
}
player.getPrefs().edit()
.remove(context.getString(R.string.caption_user_set_key)).apply();
return true;
});
// Add all available captions
for (int i = 0; i < availableLanguages.size(); i++) {
final String captionLanguage = availableLanguages.get(i);
final MenuItem captionItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION,
i + 1, Menu.NONE, captionLanguage);
captionItem.setOnMenuItemClickListener(menuItem -> {
final int textRendererIndex = player.getCaptionRendererIndex();
if (textRendererIndex != RENDERER_UNAVAILABLE) {
// DefaultTrackSelector will select for text tracks in the following order.
// When multiple tracks share the same rank, a random track will be chosen.
// 1. ANY track exactly matching preferred language name
// 2. ANY track exactly matching preferred language stem
// 3. ROLE_FLAG_CAPTION track matching preferred language stem
// 4. ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND track matching preferred language stem
// This means if a caption track of preferred language is not available,
// then an auto-generated track of that language will be chosen automatically.
player.getTrackSelector().setParameters(player.getTrackSelector()
.buildUponParameters()
.setPreferredTextLanguages(captionLanguage,
PlayerHelper.captionLanguageStemOf(captionLanguage))
.setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)
.setRendererDisabled(textRendererIndex, false));
player.getPrefs().edit().putString(context.getString(
R.string.caption_user_set_key), captionLanguage).apply();
}
return true;
});
}
captionPopupMenu.setOnDismissListener(this);
// apply caption language from previous user preference
final int textRendererIndex = player.getCaptionRendererIndex();
if (textRendererIndex == RENDERER_UNAVAILABLE) {
return;
}
// If user prefers to show no caption, then disable the renderer.
// Otherwise, DefaultTrackSelector may automatically find an available caption
// and display that.
final String userPreferredLanguage =
player.getPrefs().getString(context.getString(R.string.caption_user_set_key), null);
if (userPreferredLanguage == null) {
player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters()
.setRendererDisabled(textRendererIndex, true));
return;
}
// Only set preferred language if it does not match the user preference,
// otherwise there might be an infinite cycle at onTextTracksChanged.
final List<String> selectedPreferredLanguages =
player.getTrackSelector().getParameters().preferredTextLanguages;
if (!selectedPreferredLanguages.contains(userPreferredLanguage)) {
player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters()
.setPreferredTextLanguages(userPreferredLanguage,
PlayerHelper.captionLanguageStemOf(userPreferredLanguage))
.setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)
.setRendererDisabled(textRendererIndex, false));
}
}
protected abstract void onPlaybackSpeedClicked();
private void onQualityClicked() {
qualityPopupMenu.show();
isSomePopupMenuVisible = true;
final VideoStream videoStream = player.getSelectedVideoStream();
if (videoStream != null) {
//noinspection SetTextI18n
binding.qualityTextView.setText(MediaFormat.getNameById(videoStream.getFormatId())
+ " " + videoStream.getResolution());
}
player.saveWasPlaying();
}
/**
* Called when an item of the quality selector or the playback speed selector is selected.
*/
@Override
public boolean onMenuItemClick(@NonNull final MenuItem menuItem) {
if (DEBUG) {
Log.d(TAG, "onMenuItemClick() called with: "
+ "menuItem = [" + menuItem + "], "
+ "menuItem.getItemId = [" + menuItem.getItemId() + "]");
}
if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) {
final int menuItemIndex = menuItem.getItemId();
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
//noinspection SimplifyOptionalCallChains
if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent()) {
return true;
}
final MediaItemTag.Quality quality = currentMetadata.getMaybeQuality().get();
final List<VideoStream> availableStreams = quality.getSortedVideoStreams();
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) {
return true;
}
player.saveStreamProgressState(); //TODO added, check if good
final String newResolution = availableStreams.get(menuItemIndex).getResolution();
player.setRecovery();
player.setPlaybackQuality(newResolution);
player.reloadPlayQueueManager();
binding.qualityTextView.setText(menuItem.getTitle());
return true;
} else if (menuItem.getGroupId() == POPUP_MENU_ID_PLAYBACK_SPEED) {
final int speedIndex = menuItem.getItemId();
final float speed = PLAYBACK_SPEEDS[speedIndex];
player.setPlaybackSpeed(speed);
binding.playbackSpeed.setText(formatSpeed(speed));
}
return false;
}
/**
* Called when some popup menu is dismissed.
*/
@Override
public void onDismiss(@Nullable final PopupMenu menu) {
if (DEBUG) {
Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]");
}
isSomePopupMenuVisible = false; //TODO check if this works
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
if (selectedVideoStream != null) {
binding.qualityTextView.setText(selectedVideoStream.getResolution());
}
if (player.isPlaying()) {
hideControls(DEFAULT_CONTROLS_DURATION, 0);
hideSystemUIIfNeeded();
}
}
private void onCaptionClicked() {
if (DEBUG) {
Log.d(TAG, "onCaptionClicked() called");
}
captionPopupMenu.show();
isSomePopupMenuVisible = true;
}
public boolean isSomePopupMenuVisible() {
return isSomePopupMenuVisible;
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Captions (text tracks)
//////////////////////////////////////////////////////////////////////////*/
//region Captions (text tracks)
@Override
public void onTextTracksChanged(@NonNull final Tracks currentTracks) {
super.onTextTracksChanged(currentTracks);
final boolean trackTypeTextSupported = !currentTracks.containsType(C.TRACK_TYPE_TEXT)
|| currentTracks.isTypeSupported(C.TRACK_TYPE_TEXT, false);
if (getPlayer().getTrackSelector().getCurrentMappedTrackInfo() == null
|| !trackTypeTextSupported) {
binding.captionTextView.setVisibility(View.GONE);
return;
}
// Extract all loaded languages
final List<Tracks.Group> textTracks = currentTracks
.getGroups()
.stream()
.filter(trackGroupInfo -> C.TRACK_TYPE_TEXT == trackGroupInfo.getType())
.collect(Collectors.toList());
final List<String> availableLanguages = textTracks.stream()
.map(Tracks.Group::getMediaTrackGroup)
.filter(textTrack -> textTrack.length > 0)
.map(textTrack -> textTrack.getFormat(0).language)
.collect(Collectors.toList());
// Find selected text track
final Optional<Format> selectedTracks = textTracks.stream()
.filter(Tracks.Group::isSelected)
.filter(info -> info.getMediaTrackGroup().length >= 1)
.map(info -> info.getMediaTrackGroup().getFormat(0))
.findFirst();
// Build UI
buildCaptionMenu(availableLanguages);
//noinspection SimplifyOptionalCallChains
if (player.getTrackSelector().getParameters().getRendererDisabled(
player.getCaptionRendererIndex()) || !selectedTracks.isPresent()) {
binding.captionTextView.setText(R.string.caption_none);
} else {
binding.captionTextView.setText(selectedTracks.get().language);
}
binding.captionTextView.setVisibility(
availableLanguages.isEmpty() ? View.GONE : View.VISIBLE);
}
@Override
public void onCues(@NonNull final List<Cue> cues) {
super.onCues(cues);
binding.subtitleView.setCues(cues);
}
private void setupSubtitleView() {
setupSubtitleView(PlayerHelper.getCaptionScale(context));
final CaptionStyleCompat captionStyle = PlayerHelper.getCaptionStyle(context);
binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT);
binding.subtitleView.setStyle(captionStyle);
}
protected abstract void setupSubtitleView(float captionScale);
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Click listeners
//////////////////////////////////////////////////////////////////////////*/
//region Click listeners
@Override
public void onClick(final View v) {
if (DEBUG) {
Log.d(TAG, "onClick() called with: v = [" + v + "]");
}
if (v.getId() == binding.resizeTextView.getId()) {
onResizeClicked();
} else if (v.getId() == binding.captionTextView.getId()) {
onCaptionClicked();
} else if (v.getId() == binding.playbackLiveSync.getId()) {
player.seekToDefault();
} else if (v.getId() == binding.playPauseButton.getId()) {
player.playPause();
} else if (v.getId() == binding.playPreviousButton.getId()) {
player.playPrevious();
} else if (v.getId() == binding.playNextButton.getId()) {
player.playNext();
} else if (v.getId() == binding.moreOptionsButton.getId()) {
onMoreOptionsClicked();
} else if (v.getId() == binding.share.getId()) {
final PlayQueueItem currentItem = player.getCurrentItem();
if (currentItem != null) {
ShareUtils.shareText(context, currentItem.getTitle(),
player.getVideoUrlAtCurrentTime(), currentItem.getThumbnailUrl());
}
} else if (v.getId() == binding.playWithKodi.getId()) {
onPlayWithKodiClicked();
} else if (v.getId() == binding.openInBrowser.getId()) {
onOpenInBrowserClicked();
} else if (v.getId() == binding.fullScreenButton.getId()) {
player.setRecovery();
NavigationHelper.playOnMainPlayer(context, player.getPlayQueue(), true);
return;
} else if (v.getId() == binding.switchMute.getId()) {
player.toggleMute();
} else if (v.getId() == binding.playerCloseButton.getId()) {
// set package to this app's package to prevent the intent from being seen outside
context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER)
.setPackage(App.PACKAGE_NAME));
} else if (v.getId() == binding.playbackSpeed.getId()) {
onPlaybackSpeedClicked();
} else if (v.getId() == binding.qualityTextView.getId()) {
onQualityClicked();
}
manageControlsAfterOnClick(v);
}
/**
* Manages the controls after a click occurred on the player UI.
* @param v The view that was clicked
*/
public void manageControlsAfterOnClick(@NonNull final View v) {
if (player.getCurrentState() == STATE_COMPLETED) {
return;
}
controlsVisibilityHandler.removeCallbacksAndMessages(null);
showHideShadow(true, DEFAULT_CONTROLS_DURATION);
animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
AnimationType.ALPHA, 0, () -> {
if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) {
if (v.getId() == binding.playPauseButton.getId()
// Hide controls in fullscreen immediately
|| (v.getId() == binding.screenRotationButton.getId()
&& isFullscreen())) {
hideControls(0, 0);
} else {
hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
}
}
});
}
@Override
public boolean onLongClick(final View v) {
if (v.getId() == binding.share.getId()) {
ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime());
}
return true;
}
public boolean onKeyDown(final int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BACK:
if (DeviceUtils.isTv(context) && isControlsVisible()) {
hideControls(0, 0);
return true;
}
break;
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_CENTER:
if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus())
|| isAnyListViewOpen()) {
// do not interfere with focus in playlist and play queue etc.
break;
}
if (player.getCurrentState() == org.schabi.newpipe.player.Player.STATE_BLOCKED) {
return true;
}
if (isControlsVisible()) {
hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME);
} else {
binding.playPauseButton.requestFocus();
showControlsThenHide();
showSystemUIPartially();
return true;
}
break;
default:
break; // ignore other keys
}
return false;
}
private void onMoreOptionsClicked() {
if (DEBUG) {
Log.d(TAG, "onMoreOptionsClicked() called");
}
final boolean isMoreControlsVisible =
binding.secondaryControls.getVisibility() == View.VISIBLE;
animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION,
isMoreControlsVisible ? 0 : 180);
animate(binding.secondaryControls, !isMoreControlsVisible, DEFAULT_CONTROLS_DURATION,
AnimationType.SLIDE_AND_ALPHA, 0, () -> {
// Fix for a ripple effect on background drawable.
// When view returns from GONE state it takes more milliseconds than returning
// from INVISIBLE state. And the delay makes ripple background end to fast
if (isMoreControlsVisible) {
binding.secondaryControls.setVisibility(View.INVISIBLE);
}
});
showControls(DEFAULT_CONTROLS_DURATION);
}
private void onPlayWithKodiClicked() {
if (player.getCurrentMetadata() != null) {
player.pause();
try {
NavigationHelper.playWithKore(context, Uri.parse(player.getVideoUrl()));
} catch (final Exception e) {
if (DEBUG) {
Log.i(TAG, "Failed to start kore", e);
}
KoreUtils.showInstallKoreDialog(player.getContext());
}
}
}
private void onOpenInBrowserClicked() {
player.getCurrentStreamInfo().ifPresent(streamInfo ->
ShareUtils.openUrlInBrowser(player.getContext(), streamInfo.getOriginalUrl()));
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Video size
//////////////////////////////////////////////////////////////////////////*/
//region Video size
protected void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) {
binding.surfaceView.setResizeMode(resizeMode);
binding.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode));
}
void onResizeClicked() {
setResizeMode(nextResizeModeAndSaveToPrefs(player, binding.surfaceView.getResizeMode()));
}
@Override
public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
super.onVideoSizeChanged(videoSize);
binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height);
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// SurfaceHolderCallback helpers
//////////////////////////////////////////////////////////////////////////*/
//region SurfaceHolderCallback helpers
/**
* Connects the video surface to the exo player. This can be called anytime without the risk for
* issues to occur, since the player will run just fine when no surface is connected. Therefore
* the video surface will be setup only when all of these conditions are true: it is not already
* setup (this just prevents wasting resources to setup the surface again), there is an exo
* player, the root view is attached to a parent and the surface view is valid/unreleased (the
* latter two conditions prevent "The surface has been released" errors). So this function can
* be called many times and even while the UI is in unready states.
*/
public void setupVideoSurfaceIfNeeded() {
if (!surfaceIsSetup && player.getExoPlayer() != null
&& binding.getRoot().getParent() != null) {
// make sure there is nothing left over from previous calls
clearVideoSurface();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23
surfaceHolderCallback = new SurfaceHolderCallback(context, player.getExoPlayer());
binding.surfaceView.getHolder().addCallback(surfaceHolderCallback);
// ensure player is using an unreleased surface, which the surfaceView might not be
// when starting playback on background or during player switching
if (binding.surfaceView.getHolder().getSurface().isValid()) {
// initially set the surface manually otherwise
// onRenderedFirstFrame() will not be called
player.getExoPlayer().setVideoSurfaceHolder(binding.surfaceView.getHolder());
}
} else {
player.getExoPlayer().setVideoSurfaceView(binding.surfaceView);
}
surfaceIsSetup = true;
}
}
private void clearVideoSurface() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M // >=API23
&& surfaceHolderCallback != null) {
binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback);
surfaceHolderCallback.release();
surfaceHolderCallback = null;
}
Optional.ofNullable(player.getExoPlayer()).ifPresent(ExoPlayer::clearVideoSurface);
surfaceIsSetup = false;
}
//endregion
/*//////////////////////////////////////////////////////////////////////////
// Getters
//////////////////////////////////////////////////////////////////////////*/
//region Getters
public PlayerBinding getBinding() {
return binding;
}
public GestureDetector getGestureDetector() {
return gestureDetector;
}
//endregion
}