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. *

* 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. *

*/ @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 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 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 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 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 textTracks = currentTracks .getGroups() .stream() .filter(trackGroupInfo -> C.TRACK_TYPE_TEXT == trackGroupInfo.getType()) .collect(Collectors.toList()); final List 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 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 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 }