diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt index 52175a3bf59..ea6067067c3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt @@ -25,7 +25,7 @@ abstract class BasePlayerGestureListener( protected val binding: PlayerBinding = playerUi.binding override fun onTouch(v: View, event: MotionEvent): Boolean { - playerUi.gestureDetector.onTouchEvent(event) + playerUi.gestureDetector?.onTouchEvent(event) return false } diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java index 4a4f3095f4d..d3dba260e3c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java @@ -147,7 +147,7 @@ public void setupAfterIntent() { } @Override - BasePlayerGestureListener buildGestureListener() { + protected BasePlayerGestureListener buildGestureListener() { return new MainPlayerGestureListener(this); } diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java index b113b877399..bbeef50ace1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java @@ -102,7 +102,7 @@ public void setupAfterIntent() { } @Override - BasePlayerGestureListener buildGestureListener() { + protected BasePlayerGestureListener buildGestureListener() { return new PopupPlayerGestureListener(this); } diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java deleted file mode 100644 index 263bc71a262..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ /dev/null @@ -1,1629 +0,0 @@ -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.annotation.SuppressLint; -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.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.AppCompatImageButton; -import androidx.appcompat.widget.PopupMenu; -import androidx.core.graphics.BitmapCompat; -import androidx.core.graphics.Insets; -import androidx.core.math.MathUtils; -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.AudioStream; -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.Localization; -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.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBarChangeListener, - 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}; - - private enum PlayButtonAction { - PLAY, PAUSE, REPLAY - } - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - protected PlayerBinding binding; - private final Handler controlsVisibilityHandler = new Handler(Looper.getMainLooper()); - @Nullable - private SurfaceHolderCallback surfaceHolderCallback; - boolean surfaceIsSetup = false; - - - /*////////////////////////////////////////////////////////////////////////// - // 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_AUDIO_TRACK = 70; - 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; - private PopupMenu audioTrackPopupMenu; - 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); - audioTrackPopupMenu = new PopupMenu(themeWrapper, binding.audioTrackTextView); - 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(makeOnClickListener(this::onQualityClicked)); - binding.audioTrackTextView.setOnClickListener( - makeOnClickListener(this::onAudioTracksClicked)); - binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked)); - - binding.playbackSeekBar.setOnSeekBarChangeListener(this); - binding.captionTextView.setOnClickListener(makeOnClickListener(this::onCaptionClicked)); - binding.resizeTextView.setOnClickListener(makeOnClickListener(this::onResizeClicked)); - binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault)); - - playerGestureListener = buildGestureListener(); - gestureDetector = new GestureDetector(context, playerGestureListener); - binding.getRoot().setOnTouchListener(playerGestureListener); - - binding.repeatButton.setOnClickListener(v -> onRepeatClicked()); - binding.shuffleButton.setOnClickListener(v -> onShuffleClicked()); - - binding.playPauseButton.setOnClickListener(makeOnClickListener(player::playPause)); - binding.playPreviousButton.setOnClickListener(makeOnClickListener(player::playPrevious)); - binding.playNextButton.setOnClickListener(makeOnClickListener(player::playNext)); - - binding.moreOptionsButton.setOnClickListener( - makeOnClickListener(this::onMoreOptionsClicked)); - binding.share.setOnClickListener(makeOnClickListener(() -> { - final PlayQueueItem currentItem = player.getCurrentItem(); - if (currentItem != null) { - ShareUtils.shareText(context, currentItem.getTitle(), - player.getVideoUrlAtCurrentTime(), currentItem.getThumbnails()); - } - })); - binding.share.setOnLongClickListener(v -> { - ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime()); - return true; - }); - binding.fullscreenToggleButtonSecondaryMenu.setOnClickListener(makeOnClickListener(() -> { - player.setRecovery(); - NavigationHelper.playOnMainPlayer(context, - Objects.requireNonNull(player.getPlayQueue()), true); - })); - binding.playWithKodi.setOnClickListener(makeOnClickListener(this::onPlayWithKodiClicked)); - binding.openInBrowser.setOnClickListener(makeOnClickListener(this::onOpenInBrowserClicked)); - binding.playerCloseButton.setOnClickListener(makeOnClickListener(() -> - // 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)) - )); - binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute)); - - 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.audioTrackTextView.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.fullscreenToggleButtonSecondaryMenu.setOnClickListener(null); - binding.fullscreenToggleButton.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(); - binding.endScreen.setImageDrawable(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.audioTrackTextView.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 changes, 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 out of the visible area. - updateEndScreenThumbnail(player.getThumbnail()); - } - } - //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); - updateEndScreenThumbnail(bitmap); - } - - private void updateEndScreenThumbnail(@Nullable final Bitmap thumbnail) { - if (thumbnail == null) { - // remove end screen thumbnail - binding.endScreen.setImageDrawable(null); - return; - } - - final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail); - final Bitmap endScreenBitmap = BitmapCompat.createScaledBitmap( - thumbnail, - (int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)), - (int) endScreenHeight, - null, - 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).orElse(null), - 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 = MathUtils.clamp(uncheckedContainerLeft, - 0, binding.playbackWindowRoot.getWidth() - - binding.seekbarPreviewContainer.getWidth()); - - // 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); - } - - 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.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(); - } - - 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; - } - - /** - * Update the play/pause button (`R.id.playPauseButton`) to reflect the action - * that will be performed when the button is clicked.. - * @param action the action that is performed when the play/pause button is clicked - */ - private void updatePlayPauseButton(final PlayButtonAction action) { - final AppCompatImageButton button = binding.playPauseButton; - switch (action) { - case PLAY: - button.setContentDescription(context.getString(R.string.play)); - button.setImageResource(R.drawable.ic_play_arrow); - break; - case PAUSE: - button.setContentDescription(context.getString(R.string.pause)); - button.setImageResource(R.drawable.ic_pause); - break; - case REPLAY: - button.setContentDescription(context.getString(R.string.replay)); - button.setImageResource(R.drawable.ic_replay); - break; - } - } - //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); - - updatePlayPauseButton(PlayButtonAction.PLAY); - 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, - () -> { - updatePlayPauseButton(PlayButtonAction.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, - () -> { - updatePlayPauseButton(PlayButtonAction.PLAY); - 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, - () -> { - updatePlayPauseButton(PlayButtonAction.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(); - } - - // TODO: don’t reference internal exoplayer2 resources - @SuppressLint("PrivateResource") - @Override - public void onRepeatModeChanged(@RepeatMode final int repeatMode) { - super.onRepeatModeChanged(repeatMode); - - if (repeatMode == REPEAT_MODE_ALL) { - binding.repeatButton.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_all); - } else if (repeatMode == REPEAT_MODE_ONE) { - binding.repeatButton.setImageResource( - com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_one); - } else /* repeatMode == REPEAT_MODE_OFF */ { - binding.repeatButton.setImageResource( - com.google.android.exoplayer2.ui.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() { - player.getCurrentStreamInfo().ifPresent(info -> { - binding.qualityTextView.setVisibility(View.GONE); - binding.audioTrackTextView.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: - if (player.getCurrentMetadata() != null - && player.getCurrentMetadata().getMaybeQuality().isEmpty() - || (info.getVideoStreams().isEmpty() - && info.getVideoOnlyStreams().isEmpty())) { - break; - } - - buildQualityMenu(); - buildAudioTrackMenu(); - - 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); - - 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()); - } - qualityPopupMenu.setOnMenuItemClickListener(this); - qualityPopupMenu.setOnDismissListener(this); - - player.getSelectedVideoStream() - .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); - } - - private void buildAudioTrackMenu() { - if (audioTrackPopupMenu == null) { - return; - } - audioTrackPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_AUDIO_TRACK); - - final List availableStreams = Optional.ofNullable(player.getCurrentMetadata()) - .flatMap(MediaItemTag::getMaybeAudioTrack) - .map(MediaItemTag.AudioTrack::getAudioStreams) - .orElse(null); - if (availableStreams == null || availableStreams.size() < 2) { - return; - } - - for (int i = 0; i < availableStreams.size(); i++) { - final AudioStream audioStream = availableStreams.get(i); - audioTrackPopupMenu.getMenu().add(POPUP_MENU_ID_AUDIO_TRACK, i, Menu.NONE, - Localization.audioTrackName(context, audioStream)); - } - - player.getSelectedAudioStream() - .ifPresent(s -> binding.audioTrackTextView.setText( - Localization.audioTrackName(context, s))); - binding.audioTrackTextView.setVisibility(View.VISIBLE); - audioTrackPopupMenu.setOnMenuItemClickListener(this); - audioTrackPopupMenu.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; - - player.getSelectedVideoStream() - .map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution()) - .ifPresent(binding.qualityTextView::setText); - } - - private void onAudioTracksClicked() { - audioTrackPopupMenu.show(); - isSomePopupMenuVisible = true; - } - - /** - * 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) { - onQualityItemClick(menuItem); - return true; - } else if (menuItem.getGroupId() == POPUP_MENU_ID_AUDIO_TRACK) { - onAudioTrackItemClick(menuItem); - 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; - } - - private void onQualityItemClick(@NonNull final MenuItem menuItem) { - final int menuItemIndex = menuItem.getItemId(); - @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); - if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) { - return; - } - - final MediaItemTag.Quality quality = currentMetadata.getMaybeQuality().get(); - final List availableStreams = quality.getSortedVideoStreams(); - final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); - if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { - return; - } - - final String newResolution = availableStreams.get(menuItemIndex).getResolution(); - player.setPlaybackQuality(newResolution); - - binding.qualityTextView.setText(menuItem.getTitle()); - } - - private void onAudioTrackItemClick(@NonNull final MenuItem menuItem) { - final int menuItemIndex = menuItem.getItemId(); - @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); - if (currentMetadata == null || currentMetadata.getMaybeAudioTrack().isEmpty()) { - return; - } - - final MediaItemTag.AudioTrack audioTrack = - currentMetadata.getMaybeAudioTrack().get(); - final List availableStreams = audioTrack.getAudioStreams(); - final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex(); - if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { - return; - } - - final String newAudioTrack = availableStreams.get(menuItemIndex).getAudioTrackId(); - player.setAudioTrack(newAudioTrack); - - binding.audioTrackTextView.setText(menuItem.getTitle()); - } - - /** - * 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 - player.getSelectedVideoStream() - .ifPresent(s -> binding.qualityTextView.setText(s.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); - if (player.getTrackSelector().getParameters().getRendererDisabled( - player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) { - 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); - } - - /** - * - * @param captionScale Value returned by {@link PlayerHelper#getCaptionScale}. - */ - protected abstract void setupSubtitleView(float captionScale); - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // Click listeners - //////////////////////////////////////////////////////////////////////////*/ - //region Click listeners - - /** - * Create on-click listener which manages the player controls after the view on-click action. - * - * @param runnable The action to be executed. - * @return The view click listener. - */ - protected View.OnClickListener makeOnClickListener(@NonNull final Runnable runnable) { - return v -> { - if (DEBUG) { - Log.d(TAG, "onClick() called with: v = [" + v + "]"); - } - - runnable.run(); - - // Manages the player controls after handling the view click. - 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 == binding.playPauseButton - // Hide controls in fullscreen immediately - || (v == binding.fullscreenToggleButton && isFullscreen())) { - hideControls(0, 0); - } else { - hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); - } - } - }); - }; - } - - 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(); - KoreUtils.playWithKore(context, Uri.parse(player.getVideoUrl())); - } - } - - 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); - // Starting with ExoPlayer 2.19.0, the VideoSize will report a width and height of 0 - // if the renderer is disabled. In that case, we skip updating the aspect ratio. - if (videoSize.width == 0 || videoSize.height == 0) { - return; - } - 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(); - - 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()); - } - - surfaceIsSetup = true; - } - } - - private void clearVideoSurface() { - if (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 -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt new file mode 100644 index 00000000000..b40bd63290a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt @@ -0,0 +1,1670 @@ +package org.schabi.newpipe.player.ui + +import android.annotation.SuppressLint +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.Handler +import android.os.Looper +import android.util.Log +import android.view.GestureDetector +import android.view.Gravity +import android.view.HapticFeedbackConstants +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.appcompat.content.res.AppCompatResources +import androidx.appcompat.view.ContextThemeWrapper +import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.PopupMenu +import androidx.core.graphics.BitmapCompat +import androidx.core.graphics.Insets +import androidx.core.math.MathUtils +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.REPEAT_MODE_ALL +import com.google.android.exoplayer2.Player.REPEAT_MODE_ONE +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.MainActivity.DEBUG +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.PlayerBinding +import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamSegment +import org.schabi.newpipe.extractor.stream.VideoStream +import org.schabi.newpipe.fragments.detail.VideoDetailFragment +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.ktx.animateRotation +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.Player.RENDERER_UNAVAILABLE +import org.schabi.newpipe.player.Player.STATE_BUFFERING +import org.schabi.newpipe.player.Player.STATE_COMPLETED +import org.schabi.newpipe.player.Player.STATE_PAUSED +import org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK +import org.schabi.newpipe.player.Player.STATE_PLAYING +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.helper.PlayerHelper.formatSpeed +import org.schabi.newpipe.player.helper.PlayerHelper.getTimeString +import org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs +import org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences +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.Localization +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.ChaptersSeekBar +import org.schabi.newpipe.views.player.PlayerFastSeekOverlay + +abstract class VideoPlayerUi protected constructor( + player: Player, + playerBinding: PlayerBinding +) : PlayerUi(player), + SeekBar.OnSeekBarChangeListener, + PopupMenu.OnMenuItemClickListener, + PopupMenu.OnDismissListener { + + private enum class PlayButtonAction { + PLAY, + PAUSE, + REPLAY + } + + // time constants + // region Views + + @JvmField + var binding: PlayerBinding = playerBinding + + private val controlsVisibilityHandler = Handler(Looper.getMainLooper()) + + @JvmField + var surfaceIsSetup = false + + private var surfaceHolderCallback: SurfaceHolderCallback? = null + + // Popup menus ("popup" means that they pop up, not that they belong to the popup player) + + @JvmField + var isSomePopupMenuVisible = false + + private var qualityPopupMenu: PopupMenu? = null + private var audioTrackPopupMenu: PopupMenu? = null + + @JvmField + protected var playbackSpeedPopupMenu: PopupMenu? = null + + private var captionPopupMenu: PopupMenu? = null + + // Gestures + + internal var gestureDetector: GestureDetector? = null + private var playerGestureListener: BasePlayerGestureListener? = null + private var onLayoutChangeListener: View.OnLayoutChangeListener? = null + + private val seekbarPreviewThumbnailHolder = SeekbarPreviewThumbnailHolder() + + private var currentChapters: List = emptyList() + private var lastChapterForHaptic: StreamSegment? = null + + // endregion + + // region Constructor, setup, destroy + + init { + setupFromView() + } + + fun setupFromView() { + initViews() + initListeners() + setupPlayerSeekOverlay() + } + + private fun initViews() { + setupSubtitleView() + + binding.resizeTextView.setText( + PlayerHelper.resizeTypeOf(context, binding.surfaceView.resizeMode) + ) + + binding.playbackSeekBar.thumb + .colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) + binding.playbackSeekBar.progressDrawable + .colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY) + + val themeWrapper = ContextThemeWrapper(context, R.style.DarkPopupMenu) + + qualityPopupMenu = PopupMenu(themeWrapper, binding.qualityTextView) + audioTrackPopupMenu = PopupMenu(themeWrapper, binding.audioTrackTextView) + playbackSpeedPopupMenu = PopupMenu(context, binding.playbackSpeed) + captionPopupMenu = PopupMenu(themeWrapper, binding.captionTextView) + + binding.progressBarLoadingPanel.indeterminateDrawable + .colorFilter = PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY) + + binding.titleTextView.isSelected = true + binding.channelTextView.isSelected = true + + // Prevent hiding of bottom sheet via swipe inside queue + binding.itemsList.isNestedScrollingEnabled = false + } + + // Must be `protected` (not `internal`) so that the Java subclasses MainPlayerUi and + // PopupPlayerUi can override it. Kotlin `internal` compiles to a JVM-mangled name + // (e.g. `buildGestureListener$app_debug`) that Java cannot reference, which causes a + // "must be declared abstract or implement abstract method" compile error in those classes. + protected abstract fun buildGestureListener(): BasePlayerGestureListener + + protected open fun initListeners() { + binding.qualityTextView.setOnClickListener(makeOnClickListener(this::onQualityClicked)) + binding.audioTrackTextView.setOnClickListener( + makeOnClickListener(this::onAudioTracksClicked) + ) + binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked)) + + binding.playbackSeekBar.setOnSeekBarChangeListener(this) + binding.captionTextView.setOnClickListener(makeOnClickListener(this::onCaptionClicked)) + binding.resizeTextView.setOnClickListener(makeOnClickListener(this::onResizeClicked)) + binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault)) + + playerGestureListener = buildGestureListener() + gestureDetector = GestureDetector(context, playerGestureListener!!) + binding.root.setOnTouchListener(playerGestureListener) + + binding.repeatButton.setOnClickListener { onRepeatClicked() } + binding.shuffleButton.setOnClickListener { onShuffleClicked() } + + binding.playPauseButton.setOnClickListener(makeOnClickListener(player::playPause)) + binding.playPreviousButton.setOnClickListener(makeOnClickListener(player::playPrevious)) + binding.playNextButton.setOnClickListener(makeOnClickListener(player::playNext)) + + binding.moreOptionsButton.setOnClickListener(makeOnClickListener(this::onMoreOptionsClicked)) + binding.share.setOnClickListener( + makeOnClickListener { + val currentItem: PlayQueueItem? = player.currentItem + if (currentItem != null) { + ShareUtils.shareText( + context, + currentItem.title, + player.videoUrlAtCurrentTime, + currentItem.thumbnails + ) + } + } + ) + binding.share.setOnLongClickListener { v -> + ShareUtils.copyToClipboard(context, player.videoUrlAtCurrentTime) + true + } + binding.fullscreenToggleButtonSecondaryMenu.setOnClickListener( + makeOnClickListener { + player.setRecovery() + NavigationHelper.playOnMainPlayer( + context, + requireNotNull(player.playQueue), + true + ) + } + ) + binding.playWithKodi.setOnClickListener(makeOnClickListener(this::onPlayWithKodiClicked)) + binding.openInBrowser.setOnClickListener(makeOnClickListener(this::onOpenInBrowserClicked)) + binding.playerCloseButton.setOnClickListener( + makeOnClickListener { + // set package to this app's package to prevent the intent from being seen outside + context.sendBroadcast( + Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER) + .setPackage(App.PACKAGE_NAME) + ) + } + ) + binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute)) + + ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel) { view, windowInsets -> + val cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + if (cutout != Insets.NONE) { + view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom) + } + 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 = + View.OnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + binding.playerOverlays.setPadding( + v.paddingLeft, + v.paddingTop, + v.paddingRight, + v.paddingBottom + ) + + // 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. + val fastSeekParams = + binding.fastSeekOverlay.layoutParams as RelativeLayout.LayoutParams + fastSeekParams.leftMargin = -v.paddingRight + fastSeekParams.topMargin = -v.paddingBottom + fastSeekParams.rightMargin = -v.paddingLeft + fastSeekParams.bottomMargin = -v.paddingTop + } + binding.playbackControlRoot.addOnLayoutChangeListener(onLayoutChangeListener) + } + + protected open fun deinitListeners() { + binding.qualityTextView.setOnClickListener(null) + binding.audioTrackTextView.setOnClickListener(null) + binding.playbackSpeed.setOnClickListener(null) + binding.playbackSeekBar.setOnSeekBarChangeListener(null) + binding.captionTextView.setOnClickListener(null) + binding.resizeTextView.setOnClickListener(null) + binding.playbackLiveSync.setOnClickListener(null) + + binding.root.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.fullscreenToggleButtonSecondaryMenu.setOnClickListener(null) + binding.fullscreenToggleButton.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 fun setupPlayerSeekOverlay() { + binding.fastSeekOverlay + .seekSecondsSupplier { retrieveSeekDurationFromPreferences(player) / 1000 } + .performListener(object : PlayerFastSeekOverlay.PerformListener { + + override fun onDoubleTap() { + binding.fastSeekOverlay.animate(true, SEEK_OVERLAY_DURATION) + } + + override fun onDoubleTapEnd() { + binding.fastSeekOverlay.animate(false, SEEK_OVERLAY_DURATION) + } + + override fun getFastSeekDirection( + portion: DisplayPortion + ): PlayerFastSeekOverlay.PerformListener.FastSeekDirection { + if (player.exoPlayerIsNull()) { + // Abort seeking + playerGestureListener?.endMultiDoubleTap() + return PlayerFastSeekOverlay.PerformListener.FastSeekDirection.NONE + } + if (portion == DisplayPortion.LEFT) { + // Check if it's possible to rewind + // Small puffer to eliminate infinite rewind seeking + if (player.exoPlayer.currentPosition < 500L) { + return PlayerFastSeekOverlay.PerformListener.FastSeekDirection.NONE + } + return PlayerFastSeekOverlay.PerformListener.FastSeekDirection.BACKWARD + } else if (portion == DisplayPortion.RIGHT) { + // Check if it's possible to fast-forward + if (player.currentState == STATE_COMPLETED || + player.exoPlayer.currentPosition >= player.exoPlayer.duration + ) { + return PlayerFastSeekOverlay.PerformListener.FastSeekDirection.NONE + } + return PlayerFastSeekOverlay.PerformListener.FastSeekDirection.FORWARD + } + /* portion == DisplayPortion.MIDDLE */ + return PlayerFastSeekOverlay.PerformListener.FastSeekDirection.NONE + } + + override fun seek(forward: Boolean) { + playerGestureListener?.keepInDoubleTapMode() + if (forward) { + player.fastForward() + } else { + player.fastRewind() + } + } + }) + playerGestureListener?.doubleTapControls(binding.fastSeekOverlay) + } + + fun deinitPlayerSeekOverlay() { + binding.fastSeekOverlay + .seekSecondsSupplier(null) + .performListener(null) + } + + override fun setupAfterIntent() { + super.setupAfterIntent() + setupElementsVisibility() + setupElementsSize(context.resources) + binding.root.visibility = View.VISIBLE + binding.playPauseButton.requestFocus() + } + + override fun initPlayer() { + super.initPlayer() + setupVideoSurfaceIfNeeded() + } + + override fun initPlayback() { + super.initPlayback() + + // #6825 - Ensure that the shuffle-button is in the correct state on the UI + setShuffleButton(player.exoPlayer.shuffleModeEnabled) + + // Seed chapter markers in case onMetadataChanged already fired before this UI was + // attached to the player (e.g. when switching player types or restoring a session). + player.currentStreamInfo.ifPresent { info -> + currentChapters = info.streamSegments ?: emptyList() + lastChapterForHaptic = null + (binding.playbackSeekBar as ChaptersSeekBar).setChapters(currentChapters, info.duration) + } + } + + abstract fun removeViewFromParent() + + override fun destroyPlayer() { + super.destroyPlayer() + clearVideoSurface() + } + + override fun destroy() { + super.destroy() + binding.endScreen.setImageDrawable(null) + deinitPlayerSeekOverlay() + deinitListeners() + } + + protected open fun setupElementsVisibility() { + setMuteButton(player.isMuted) + binding.moreOptionsButton.animateRotation(DEFAULT_CONTROLS_DURATION, 0) + } + + protected abstract fun setupElementsSize(resources: Resources) + + protected fun setupElementsSize( + buttonsMinWidth: Int, + playerTopPad: Int, + controlsPad: Int, + buttonsPad: Int + ) { + binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0) + binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0) + binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad) + binding.audioTrackTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad) + binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad) + binding.playbackSpeed.minimumWidth = buttonsMinWidth + binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad) + } + // endregion + + // region Broadcast receiver + + override fun onBroadcastReceived(intent: Intent) { + super.onBroadcastReceived(intent) + if (Intent.ACTION_CONFIGURATION_CHANGED == intent.action) { + // When the orientation changes, 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 out of the visible area. + updateEndScreenThumbnail(player.thumbnail) + } + } + // endregion + + // 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 fun onThumbnailLoaded(bitmap: Bitmap?) { + super.onThumbnailLoaded(bitmap) + updateEndScreenThumbnail(bitmap) + } + + private fun updateEndScreenThumbnail(thumbnail: Bitmap?) { + if (thumbnail == null) { + // remove end screen thumbnail + binding.endScreen.setImageDrawable(null) + return + } + + val endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail) + val endScreenBitmap = BitmapCompat.createScaledBitmap( + thumbnail, + (thumbnail.width / (thumbnail.height / endScreenHeight)).toInt(), + endScreenHeight.toInt(), + null, + true + ) + + if (DEBUG) { + Log.d( + TAG, + "Thumbnail - onThumbnailLoaded() called with: " + + "currentThumbnail = [$thumbnail], " + + "${thumbnail.width}x${thumbnail.height}" + + ", scaled end screen height = $endScreenHeight" + + ", scaled end screen width = ${endScreenBitmap.width}" + ) + } + + binding.endScreen.setImageBitmap(endScreenBitmap) + } + + protected abstract fun calculateMaxEndScreenThumbnailHeight(bitmap: Bitmap): Float + // endregion + + // region Progress loop and updates + + override fun onUpdateProgress(currentProgress: Int, duration: Int, bufferPercent: Int) { + if (duration != binding.playbackSeekBar.max) { + setVideoDurationToControls(duration) + } + + // If chapter metadata callback was missed, recover from currentStreamInfo. + if (currentChapters.isEmpty()) { + player.currentStreamInfo.ifPresent { info -> + val streamSegments = info.streamSegments ?: emptyList() + if (streamSegments.isNotEmpty()) { + currentChapters = streamSegments + lastChapterForHaptic = null + (binding.playbackSeekBar as? ChaptersSeekBar) + ?.setChapters(currentChapters, info.duration) + } + } + } + + if (player.currentState != STATE_PAUSED) { + updatePlayBackElementsCurrentDuration(currentProgress) + } + if (player.isLoading || bufferPercent > 90) { + binding.playbackSeekBar.secondaryProgress = + (binding.playbackSeekBar.max * (bufferPercent.toFloat() / 100)).toInt() + } + + if (DEBUG && bufferPercent % 20 == 0) { // Limit log + Log.d( + TAG, + "notifyProgressUpdateToListeners() called with: " + + "isVisible = $isControlsVisible, " + + "currentProgress = [$currentProgress], " + + "duration = [$duration], bufferPercent = [$bufferPercent]" + ) + } + binding.playbackLiveSync.isClickable = !player.isLiveEdge + } + + /** + * Sets the current duration into the corresponding elements. + * + * @param currentProgress the current progress, in milliseconds + */ + private fun updatePlayBackElementsCurrentDuration(currentProgress: Int) { + // Don't set seekbar progress while user is seeking + if (player.currentState != STATE_PAUSED_SEEK) { + binding.playbackSeekBar.progress = currentProgress + } + binding.playbackCurrentTime.text = getTimeString(currentProgress.toLong()) + } + + /** + * Sets the video duration time into all control components (e.g. seekbar). + * + * @param duration the video duration, in milliseconds + */ + private fun setVideoDurationToControls(duration: Int) { + binding.playbackEndTime.text = getTimeString(duration.toLong()) + + binding.playbackSeekBar.max = 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( + retrieveSeekDurationFromPreferences(player) + ) + } + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + // 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.text = getTimeString(progress.toLong()) + + // Seekbar Preview Thumbnail + SeekbarPreviewThumbnailHelper + .tryResizeAndSetSeekbarPreviewThumbnail( + player.context, + seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null), + binding.currentSeekbarPreviewThumbnail, + binding.subtitleView::getWidth + ) + + // Chapter title tooltip + haptic feedback at chapter boundaries + if (currentChapters.isNotEmpty()) { + val chapter = getChapterAtMs(progress.toLong()) + if (chapter?.title != null) { + binding.currentChapterTitle.text = chapter.title + } + if (chapter !== lastChapterForHaptic) { + lastChapterForHaptic = chapter + seekBar.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + } + } + + adjustSeekbarPreviewContainer() + } + + private fun adjustSeekbarPreviewContainer() { + try { + // Should only be required when an error occurred before + // and the layout was positioned in the center + binding.bottomSeekbarPreviewLayout.gravity = Gravity.NO_GRAVITY + + // Calculate the current left position of seekbar progress in px + // More info: https://stackoverflow.com/q/20493577 + val currentSeekbarLeft = + binding.playbackSeekBar.left + + binding.playbackSeekBar.paddingLeft + + binding.playbackSeekBar.thumb.bounds.left + + // Calculate the (unchecked) left position of the container + val uncheckedContainerLeft = + currentSeekbarLeft - (binding.seekbarPreviewContainer.width / 2) + + // Fix the position so it's within the boundaries + val checkedContainerLeft = MathUtils.clamp( + uncheckedContainerLeft, + 0, + binding.playbackWindowRoot.width - binding.seekbarPreviewContainer.width + ) + + // See also: https://stackoverflow.com/a/23249734 + val params = LinearLayout.LayoutParams(binding.seekbarPreviewContainer.layoutParams) + params.marginStart = checkedContainerLeft + binding.seekbarPreviewContainer.layoutParams = params + } catch (ex: Exception) { + Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex) + // Fallback - position in the middle + binding.bottomSeekbarPreviewLayout.gravity = Gravity.CENTER + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + if (DEBUG) { + Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [$seekBar]") + } + if (player.currentState != STATE_PAUSED_SEEK) { + player.changeState(STATE_PAUSED_SEEK) + } + + showControls(0) + binding.currentDisplaySeek.animate(true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA) + binding.currentSeekbarPreviewThumbnail.animate(true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA) + if (currentChapters.isNotEmpty()) { + binding.currentChapterTitle.animate(true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA) + } + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + if (DEBUG) { + Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [$seekBar]") + } + + player.seekTo(seekBar.progress.toLong()) + if (player.exoPlayer.duration == seekBar.progress.toLong()) { + player.exoPlayer.play() + } + + binding.playbackCurrentTime.text = getTimeString(seekBar.progress.toLong()) + binding.currentDisplaySeek.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + binding.currentSeekbarPreviewThumbnail.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + binding.currentChapterTitle.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + + if (player.currentState == STATE_PAUSED_SEEK) { + player.changeState(STATE_BUFFERING) + } + if (!player.isProgressLoopRunning) { + player.startProgressLoop() + } + + showControlsThenHide() + } + + /** + * Returns the chapter active at the given playback position, or `null` if + * `currentChapters` is empty. + * + * @param positionMs playback position in milliseconds + * @return the [StreamSegment] whose window contains `positionMs` + */ + private fun getChapterAtMs(positionMs: Long): StreamSegment? { + var result: StreamSegment? = null + for (seg in currentChapters) { + if (seg.startTimeSeconds * 1000L > positionMs) { + break + } + result = seg + } + return result + } + // endregion + + // region Controls showing / hiding + + val isControlsVisible: Boolean + get() = binding.playbackControlRoot.visibility == View.VISIBLE + + fun showControlsThenHide() { + if (DEBUG) { + Log.d(TAG, "showControlsThenHide() called") + } + + showOrHideButtons() + showSystemUIPartially() + + val hideTime = + if (binding.playbackControlRoot.isInTouchMode) { + DEFAULT_CONTROLS_HIDE_TIME + } else { + DPAD_CONTROLS_HIDE_TIME + } + + showHideShadow(true, DEFAULT_CONTROLS_DURATION) + binding.playbackControlRoot.animate( + true, + DEFAULT_CONTROLS_DURATION, + AnimationType.ALPHA, + 0 + ) { hideControls(DEFAULT_CONTROLS_DURATION, hideTime) } + } + + fun showControls(duration: Long) { + if (DEBUG) { + Log.d(TAG, "showControls() called") + } + showOrHideButtons() + showSystemUIPartially() + controlsVisibilityHandler.removeCallbacksAndMessages(null) + showHideShadow(true, duration) + binding.playbackControlRoot.animate(true, duration) + } + + fun hideControls(duration: Long, delay: Long) { + if (DEBUG) { + Log.d( + TAG, + "hideControls() called with: duration = [$duration]" + + ", delay = [$delay]" + ) + } + + showOrHideButtons() + + controlsVisibilityHandler.removeCallbacksAndMessages(null) + controlsVisibilityHandler.postDelayed({ + showHideShadow(false, duration) + binding.playbackControlRoot.animate( + false, + duration, + AnimationType.ALPHA, + 0 + ) { hideSystemUIIfNeeded() } + }, delay) + } + + fun showHideShadow(show: Boolean, duration: Long) { + binding.playbackControlsShadow.animate(show, duration, AnimationType.ALPHA, 0, null) + binding.playerTopShadow.animate(show, duration, AnimationType.ALPHA, 0, null) + binding.playerBottomShadow.animate(show, duration, AnimationType.ALPHA, 0, null) + } + + protected open fun showOrHideButtons() { + val playQueue: PlayQueue = player.playQueue ?: return + + val showPrev = playQueue.index != 0 + val showNext = playQueue.index + 1 != playQueue.size() + + binding.playPreviousButton.visibility = if (showPrev) View.VISIBLE else View.INVISIBLE + binding.playPreviousButton.alpha = if (showPrev) 1.0f else 0.0f + binding.playNextButton.visibility = if (showNext) View.VISIBLE else View.INVISIBLE + binding.playNextButton.alpha = if (showNext) 1.0f else 0.0f + } + + protected open fun showSystemUIPartially() { + // system UI is really changed only by MainPlayerUi, so overridden there + } + + protected open fun hideSystemUIIfNeeded() { + // system UI is really changed only by MainPlayerUi, so overridden there + } + + protected open fun isAnyListViewOpen(): Boolean { + // only MainPlayerUi has list views for the queue and for segments, so overridden there + return false + } + + open val isFullscreen: Boolean + // only MainPlayerUi can be in fullscreen, so overridden there + get() = false + + /** + * Update the play/pause button to reflect the action that will be performed when clicked. + * @param action the action that is performed when the play/pause button is clicked + */ + private fun updatePlayPauseButton(action: PlayButtonAction) { + val button: AppCompatImageButton = binding.playPauseButton + when (action) { + PlayButtonAction.PLAY -> { + button.contentDescription = context.getString(R.string.play) + button.setImageResource(R.drawable.ic_play_arrow) + } + + PlayButtonAction.PAUSE -> { + button.contentDescription = context.getString(R.string.pause) + button.setImageResource(R.drawable.ic_pause) + } + + PlayButtonAction.REPLAY -> { + button.contentDescription = context.getString(R.string.replay) + button.setImageResource(R.drawable.ic_replay) + } + } + } + // endregion + + // region Playback states + + override fun onPrepared() { + super.onPrepared() + setVideoDurationToControls(player.exoPlayer.duration.toInt()) + binding.playbackSpeed.text = formatSpeed(player.playbackSpeed.toDouble()) + } + + override fun onBlocked() { + super.onBlocked() + + // if we are e.g. switching players, hide controls + hideControls(DEFAULT_CONTROLS_DURATION, 0) + + binding.playbackSeekBar.isEnabled = false + binding.playbackSeekBar.thumb + .colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) + + binding.loadingPanel.setBackgroundColor(Color.BLACK) + binding.loadingPanel.animate(true, 0) + binding.surfaceForeground.animate(true, 100) + + updatePlayPauseButton(PlayButtonAction.PLAY) + animatePlayButtons(false, 100) + binding.root.keepScreenOn = false + } + + override fun onPlaying() { + super.onPlaying() + + updateStreamRelatedViews() + + binding.playbackSeekBar.isEnabled = true + binding.playbackSeekBar.thumb + .colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) + + binding.loadingPanel.visibility = View.GONE + + binding.currentDisplaySeek.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + + binding.playPauseButton.animate(false, 80, AnimationType.SCALE_AND_ALPHA, 0) { + updatePlayPauseButton(PlayButtonAction.PAUSE) + animatePlayButtons(true, 200) + if (!isAnyListViewOpen()) { + binding.playPauseButton.requestFocus() + } + } + + binding.root.keepScreenOn = true + } + + override fun onBuffering() { + super.onBuffering() + binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT) + binding.loadingPanel.visibility = View.VISIBLE + binding.root.keepScreenOn = true + } + + override fun 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.visibility = View.GONE + + binding.playPauseButton.animate(false, 80, AnimationType.SCALE_AND_ALPHA, 0) { + updatePlayPauseButton(PlayButtonAction.PLAY) + animatePlayButtons(true, 200) + if (!isAnyListViewOpen()) { + binding.playPauseButton.requestFocus() + } + } + } + + binding.root.keepScreenOn = false + } + + override fun onPausedSeek() { + super.onPausedSeek() + animatePlayButtons(false, 100) + binding.root.keepScreenOn = true + } + + override fun onCompleted() { + super.onCompleted() + + binding.playPauseButton.animate(false, 0, AnimationType.SCALE_AND_ALPHA, 0) { + updatePlayPauseButton(PlayButtonAction.REPLAY) + animatePlayButtons(true, DEFAULT_CONTROLS_DURATION) + } + + binding.root.keepScreenOn = false + + // When a (short) video ends the elements have to display the correct values - see #6180 + updatePlayBackElementsCurrentDuration(binding.playbackSeekBar.max) + + showControls(500) + binding.currentDisplaySeek.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + binding.loadingPanel.visibility = View.GONE + binding.surfaceForeground.animate(true, 100) + } + + private fun animatePlayButtons(show: Boolean, duration: Long) { + binding.playPauseButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) + + val playQueue: PlayQueue = player.playQueue ?: return + + if (!show || playQueue.index > 0) { + binding.playPreviousButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) + } + if (!show || playQueue.index + 1 < playQueue.size()) { + binding.playNextButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) + } + } + // endregion + + // region Repeat, shuffle, mute + + fun onRepeatClicked() { + if (DEBUG) { + Log.d(TAG, "onRepeatClicked() called") + } + player.cycleNextRepeatMode() + } + + fun onShuffleClicked() { + if (DEBUG) { + Log.d(TAG, "onShuffleClicked() called") + } + player.toggleShuffleModeEnabled() + } + + // TODO: don’t reference internal exoplayer2 resources + @SuppressLint("PrivateResource") + override fun onRepeatModeChanged(@RepeatMode repeatMode: Int) { + super.onRepeatModeChanged(repeatMode) + + when (repeatMode) { + REPEAT_MODE_ALL -> binding.repeatButton.setImageResource( + com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_all + ) + + REPEAT_MODE_ONE -> binding.repeatButton.setImageResource( + com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_one + ) + + else -> binding.repeatButton.setImageResource( + com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_off + ) + } + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled) + setShuffleButton(shuffleModeEnabled) + } + + override fun onMuteUnmuteChanged(isMuted: Boolean) { + super.onMuteUnmuteChanged(isMuted) + setMuteButton(isMuted) + } + + private fun setMuteButton(isMuted: Boolean) { + binding.switchMute.setImageDrawable( + AppCompatResources.getDrawable( + context, + if (isMuted) R.drawable.ic_volume_off else R.drawable.ic_volume_up + ) + ) + } + + private fun setShuffleButton(shuffled: Boolean) { + binding.shuffleButton.imageAlpha = if (shuffled) 255 else 77 + } + // endregion + + // region Other player listeners + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + super.onPlaybackParametersChanged(playbackParameters) + binding.playbackSpeed.text = formatSpeed(playbackParameters.speed.toDouble()) + } + + override fun onRenderedFirstFrame() { + super.onRenderedFirstFrame() + // TODO check if this causes black screen when switching to fullscreen + binding.surfaceForeground.animate(false, DEFAULT_CONTROLS_DURATION) + } + // endregion + + // region Metadata & stream related views + + override fun onMetadataChanged(info: StreamInfo) { + super.onMetadataChanged(info) + + updateStreamRelatedViews() + + binding.titleTextView.text = info.name + binding.channelTextView.text = info.uploaderName + + seekbarPreviewThumbnailHolder.resetFrom(player.context, info.previewFrames) + + // Chapter markers on seekbar + val rawSegments = info.streamSegments + currentChapters = rawSegments ?: emptyList() + lastChapterForHaptic = null + val seekBar = binding.playbackSeekBar + if (seekBar is ChaptersSeekBar) { + seekBar.setChapters(currentChapters, info.duration) + } + binding.currentChapterTitle.visibility = View.GONE + } + + private fun updateStreamRelatedViews() { + player.currentStreamInfo.ifPresent { info -> + binding.qualityTextView.visibility = View.GONE + binding.audioTrackTextView.visibility = View.GONE + binding.playbackSpeed.visibility = View.GONE + + binding.playbackEndTime.visibility = View.GONE + binding.playbackLiveSync.visibility = View.GONE + + when (info.streamType) { + org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM, + org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM -> { + binding.surfaceView.visibility = View.GONE + binding.endScreen.visibility = View.VISIBLE + binding.playbackEndTime.visibility = View.VISIBLE + } + + org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM -> { + binding.surfaceView.visibility = View.GONE + binding.endScreen.visibility = View.VISIBLE + binding.playbackLiveSync.visibility = View.VISIBLE + } + + org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM -> { + binding.surfaceView.visibility = View.VISIBLE + binding.endScreen.visibility = View.GONE + binding.playbackLiveSync.visibility = View.VISIBLE + } + + org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM, + org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM -> { + val meta = player.currentMetadata + if (!( + (meta != null && meta.maybeQuality.isEmpty()) || + (info.videoStreams.isEmpty() && info.videoOnlyStreams.isEmpty()) + ) + ) { + buildQualityMenu() + buildAudioTrackMenu() + binding.qualityTextView.visibility = View.VISIBLE + binding.surfaceView.visibility = View.VISIBLE + // fallthrough to default + binding.endScreen.visibility = View.GONE + binding.playbackEndTime.visibility = View.VISIBLE + } + } + + else -> { + binding.endScreen.visibility = View.GONE + binding.playbackEndTime.visibility = View.VISIBLE + } + } + + buildPlaybackSpeedMenu() + binding.playbackSpeed.visibility = View.VISIBLE + } + } + // endregion + + // region Popup menus ("popup" means that they pop up, not that they belong to the popup player) + + private fun buildQualityMenu() { + qualityPopupMenu ?: return + qualityPopupMenu!!.menu.removeGroup(POPUP_MENU_ID_QUALITY) + + val availableStreams = player.currentMetadata?.maybeQuality?.orElse(null)?.sortedVideoStreams + ?: return + + for (i in availableStreams.indices) { + val videoStream: VideoStream = availableStreams[i] + qualityPopupMenu!!.menu.add( + POPUP_MENU_ID_QUALITY, + i, + Menu.NONE, + MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution + ) + } + qualityPopupMenu!!.setOnMenuItemClickListener(this) + qualityPopupMenu!!.setOnDismissListener(this) + + player.selectedVideoStream + .ifPresent { s -> binding.qualityTextView.text = s.resolution } + } + + private fun buildAudioTrackMenu() { + audioTrackPopupMenu ?: return + audioTrackPopupMenu!!.menu.removeGroup(POPUP_MENU_ID_AUDIO_TRACK) + + val availableStreams = player.currentMetadata?.maybeAudioTrack?.orElse(null)?.audioStreams + ?: return + if (availableStreams.size < 2) { + return + } + + for (i in availableStreams.indices) { + val audioStream: AudioStream = availableStreams[i] + audioTrackPopupMenu!!.menu.add( + POPUP_MENU_ID_AUDIO_TRACK, + i, + Menu.NONE, + Localization.audioTrackName(context, audioStream) + ) + } + + player.selectedAudioStream + .ifPresent { s -> + binding.audioTrackTextView.text = Localization.audioTrackName(context, s) + } + binding.audioTrackTextView.visibility = View.VISIBLE + audioTrackPopupMenu!!.setOnMenuItemClickListener(this) + audioTrackPopupMenu!!.setOnDismissListener(this) + } + + private fun buildPlaybackSpeedMenu() { + playbackSpeedPopupMenu ?: return + playbackSpeedPopupMenu!!.menu.removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED) + + for (i in PLAYBACK_SPEEDS.indices) { + playbackSpeedPopupMenu!!.menu.add( + POPUP_MENU_ID_PLAYBACK_SPEED, + i, + Menu.NONE, + formatSpeed(PLAYBACK_SPEEDS[i].toDouble()) + ) + } + binding.playbackSpeed.text = formatSpeed(player.playbackSpeed.toDouble()) + playbackSpeedPopupMenu!!.setOnMenuItemClickListener(this) + playbackSpeedPopupMenu!!.setOnDismissListener(this) + } + + private fun buildCaptionMenu(availableLanguages: List) { + captionPopupMenu ?: return + captionPopupMenu!!.menu.removeGroup(POPUP_MENU_ID_CAPTION) + + captionPopupMenu!!.setOnDismissListener(this) + + // Add option for turning off caption + val captionOffItem = captionPopupMenu!!.menu.add( + POPUP_MENU_ID_CAPTION, + 0, + Menu.NONE, + R.string.caption_none + ) + captionOffItem.setOnMenuItemClickListener { + val textRendererIndex = player.captionRendererIndex + if (textRendererIndex != RENDERER_UNAVAILABLE) { + player.trackSelector.setParameters( + player.trackSelector.buildUponParameters() + .setRendererDisabled(textRendererIndex, true) + ) + } + player.prefs.edit() + .remove(context.getString(R.string.caption_user_set_key)).apply() + true + } + + // Add all available captions + for (i in availableLanguages.indices) { + val captionLanguage = availableLanguages[i] + val captionItem = captionPopupMenu!!.menu.add( + POPUP_MENU_ID_CAPTION, + i + 1, + Menu.NONE, + captionLanguage + ) + captionItem.setOnMenuItemClickListener { + val textRendererIndex = player.captionRendererIndex + 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.trackSelector.setParameters( + player.trackSelector.buildUponParameters() + .setPreferredTextLanguages( + captionLanguage, + PlayerHelper.captionLanguageStemOf(captionLanguage) + ) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + .setRendererDisabled(textRendererIndex, false) + ) + player.prefs.edit().putString( + context.getString(R.string.caption_user_set_key), + captionLanguage + ).apply() + } + true + } + } + captionPopupMenu!!.setOnDismissListener(this) + + // apply caption language from previous user preference + val textRendererIndex = player.captionRendererIndex + 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. + val userPreferredLanguage = + player.prefs.getString(context.getString(R.string.caption_user_set_key), null) + if (userPreferredLanguage == null) { + player.trackSelector.setParameters( + player.trackSelector.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. + val selectedPreferredLanguages = + player.trackSelector.parameters.preferredTextLanguages + if (!selectedPreferredLanguages.contains(userPreferredLanguage)) { + player.trackSelector.setParameters( + player.trackSelector.buildUponParameters() + .setPreferredTextLanguages( + userPreferredLanguage, + PlayerHelper.captionLanguageStemOf(userPreferredLanguage) + ) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + .setRendererDisabled(textRendererIndex, false) + ) + } + } + + protected abstract fun onPlaybackSpeedClicked() + + private fun onQualityClicked() { + qualityPopupMenu!!.show() + isSomePopupMenuVisible = true + + player.selectedVideoStream + .map { s -> MediaFormat.getNameById(s.formatId) + " " + s.resolution } + .ifPresent { text -> binding.qualityTextView.text = text } + } + + private fun onAudioTracksClicked() { + audioTrackPopupMenu!!.show() + isSomePopupMenuVisible = true + } + + /** + * Called when an item of the quality selector or the playback speed selector is selected. + */ + override fun onMenuItemClick(menuItem: MenuItem): Boolean { + if (DEBUG) { + Log.d( + TAG, + "onMenuItemClick() called with: " + + "menuItem = [$menuItem], " + + "menuItem.getItemId = [${menuItem.itemId}]" + ) + } + + return when (menuItem.groupId) { + POPUP_MENU_ID_QUALITY -> { + onQualityItemClick(menuItem) + true + } + + POPUP_MENU_ID_AUDIO_TRACK -> { + onAudioTrackItemClick(menuItem) + true + } + + POPUP_MENU_ID_PLAYBACK_SPEED -> { + val speedIndex = menuItem.itemId + val speed = PLAYBACK_SPEEDS[speedIndex] + player.setPlaybackSpeed(speed) + binding.playbackSpeed.text = formatSpeed(speed.toDouble()) + false + } + + else -> false + } + } + + private fun onQualityItemClick(menuItem: MenuItem) { + val menuItemIndex = menuItem.itemId + val currentMetadata: MediaItemTag = player.currentMetadata ?: return + if (currentMetadata.maybeQuality.isEmpty()) { + return + } + + val quality = currentMetadata.maybeQuality.get() + val availableStreams = quality.sortedVideoStreams + val selectedStreamIndex = quality.selectedVideoStreamIndex + if (selectedStreamIndex == menuItemIndex || availableStreams.size <= menuItemIndex) { + return + } + + val newResolution = availableStreams[menuItemIndex].resolution + player.setPlaybackQuality(newResolution) + + binding.qualityTextView.text = menuItem.title + } + + private fun onAudioTrackItemClick(menuItem: MenuItem) { + val menuItemIndex = menuItem.itemId + val currentMetadata: MediaItemTag = player.currentMetadata ?: return + if (currentMetadata.maybeAudioTrack.isEmpty()) { + return + } + + val audioTrack = currentMetadata.maybeAudioTrack.get() + val availableStreams = audioTrack.audioStreams + val selectedStreamIndex = audioTrack.selectedAudioStreamIndex + if (selectedStreamIndex == menuItemIndex || availableStreams.size <= menuItemIndex) { + return + } + + val newAudioTrack = availableStreams[menuItemIndex].audioTrackId + player.setAudioTrack(newAudioTrack) + + binding.audioTrackTextView.text = menuItem.title + } + + /** + * Called when some popup menu is dismissed. + */ + override fun onDismiss(menu: PopupMenu?) { + if (DEBUG) { + Log.d(TAG, "onDismiss() called with: menu = [$menu]") + } + isSomePopupMenuVisible = false // TODO check if this works + player.selectedVideoStream + .ifPresent { s -> binding.qualityTextView.text = s.resolution } + + if (player.isPlaying) { + hideControls(DEFAULT_CONTROLS_DURATION, 0) + hideSystemUIIfNeeded() + } + } + + private fun onCaptionClicked() { + if (DEBUG) { + Log.d(TAG, "onCaptionClicked() called") + } + captionPopupMenu!!.show() + isSomePopupMenuVisible = true + } + + // endregion + + // region Captions (text tracks) + + override fun onTextTracksChanged(currentTracks: Tracks) { + super.onTextTracksChanged(currentTracks) + + val trackTypeTextSupported = !currentTracks.containsType(C.TRACK_TYPE_TEXT) || + currentTracks.isTypeSupported(C.TRACK_TYPE_TEXT, false) + if (getPlayer().trackSelector.currentMappedTrackInfo == null || !trackTypeTextSupported) { + binding.captionTextView.visibility = View.GONE + return + } + + // Extract all loaded languages + val textTracks: List = currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } + val availableLanguages = textTracks + .mapNotNull { group -> + group.mediaTrackGroup.takeIf { it.length > 0 }?.getFormat(0)?.language + } + + // Find selected text track + val selectedTrack: Format? = textTracks + .firstOrNull { it.isSelected && it.mediaTrackGroup.length >= 1 } + ?.mediaTrackGroup?.getFormat(0) + + // Build UI + buildCaptionMenu(availableLanguages) + if (player.trackSelector.parameters.getRendererDisabled(player.captionRendererIndex) || + selectedTrack == null + ) { + binding.captionTextView.setText(R.string.caption_none) + } else { + binding.captionTextView.text = selectedTrack.language + } + binding.captionTextView.visibility = + if (availableLanguages.isEmpty()) View.GONE else View.VISIBLE + } + + override fun onCues(cues: List) { + super.onCues(cues) + binding.subtitleView.setCues(cues) + } + + private fun setupSubtitleView() { + setupSubtitleView(PlayerHelper.getCaptionScale(context)) + val captionStyle = PlayerHelper.getCaptionStyle(context) + binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT) + binding.subtitleView.setStyle(captionStyle) + } + + /** + * @param captionScale Value returned by [PlayerHelper.getCaptionScale]. + */ + protected abstract fun setupSubtitleView(captionScale: Float) + // endregion + + // region Click listeners + + /** + * Create on-click listener which manages the player controls after the view on-click action. + * + * @param runnable The action to be executed. + * @return The view click listener. + */ + protected fun makeOnClickListener(runnable: Runnable): View.OnClickListener { + return View.OnClickListener { v -> + if (DEBUG) { + Log.d(TAG, "onClick() called with: v = [$v]") + } + + runnable.run() + + // Manages the player controls after handling the view click. + if (player.currentState == STATE_COMPLETED) { + return@OnClickListener + } + controlsVisibilityHandler.removeCallbacksAndMessages(null) + showHideShadow(true, DEFAULT_CONTROLS_DURATION) + binding.playbackControlRoot.animate( + true, + DEFAULT_CONTROLS_DURATION, + AnimationType.ALPHA, + 0 + ) { + if (player.currentState == STATE_PLAYING && !isSomePopupMenuVisible) { + if (v == binding.playPauseButton || + // Hide controls in fullscreen immediately + (v == binding.fullscreenToggleButton && isFullscreen) + ) { + hideControls(0, 0) + } else { + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME) + } + } + } + } + } + + open fun onKeyDown(keyCode: Int): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> { + if (DeviceUtils.isTv(context) && isControlsVisible) { + hideControls(0, 0) + return true + } + } + + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_CENTER -> { + if ((binding.root.hasFocus() && !binding.playbackControlRoot.hasFocus()) || + isAnyListViewOpen() + ) { + // do not interfere with focus in playlist and play queue etc. + return false + } + + if (player.currentState == 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 + } + } + + else -> { /* ignore other keys */ } + } + + return false + } + + private fun onMoreOptionsClicked() { + if (DEBUG) { + Log.d(TAG, "onMoreOptionsClicked() called") + } + + val isMoreControlsVisible = binding.secondaryControls.visibility == View.VISIBLE + + binding.moreOptionsButton.animateRotation( + DEFAULT_CONTROLS_DURATION, + if (isMoreControlsVisible) 0 else 180 + ) + binding.secondaryControls.animate( + !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.visibility = View.INVISIBLE + } + } + showControls(DEFAULT_CONTROLS_DURATION) + } + + private fun onPlayWithKodiClicked() { + if (player.currentMetadata != null) { + player.pause() + KoreUtils.playWithKore(context, Uri.parse(player.videoUrl)) + } + } + + private fun onOpenInBrowserClicked() { + player.currentStreamInfo.ifPresent { streamInfo -> + ShareUtils.openUrlInBrowser(player.context, streamInfo.originalUrl) + } + } + // endregion + + // region Video size + + protected fun setResizeMode(resizeMode: Int) { + binding.surfaceView.setResizeMode(resizeMode) + binding.resizeTextView.text = PlayerHelper.resizeTypeOf(context, resizeMode) + } + + fun onResizeClicked() { + setResizeMode(nextResizeModeAndSaveToPrefs(player, binding.surfaceView.resizeMode)) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + super.onVideoSizeChanged(videoSize) + // Starting with ExoPlayer 2.19.0, the VideoSize will report a width and height of 0 + // if the renderer is disabled. In that case, we skip updating the aspect ratio. + if (videoSize.width == 0 || videoSize.height == 0) { + return + } + binding.surfaceView.setAspectRatio(videoSize.width.toFloat() / videoSize.height) + } + // endregion + + // 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. + */ + fun setupVideoSurfaceIfNeeded() { + if (!surfaceIsSetup && player.exoPlayer != null && binding.root.parent != null) { + // make sure there is nothing left over from previous calls + clearVideoSurface() + + surfaceHolderCallback = + SurfaceHolderCallback(context, player.exoPlayer) + binding.surfaceView.holder.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.holder.surface.isValid) { + // initially set the surface manually otherwise + // onRenderedFirstFrame() will not be called + player.exoPlayer.setVideoSurfaceHolder(binding.surfaceView.holder) + } + + surfaceIsSetup = true + } + } + + private fun clearVideoSurface() { + if (surfaceHolderCallback != null) { + binding.surfaceView.holder.removeCallback(surfaceHolderCallback) + surfaceHolderCallback!!.release() + surfaceHolderCallback = null + } + player.exoPlayer?.clearVideoSurface() + surfaceIsSetup = false + } + // endregion + + // region Getters + + fun getBinding(): PlayerBinding = binding + // endregion + + companion object { + private val TAG = VideoPlayerUi::class.java.simpleName + + // time constants + @JvmField + val DEFAULT_CONTROLS_DURATION: Long = 300 // 300 millis + + @JvmField + val DEFAULT_CONTROLS_HIDE_TIME: Long = 2000 // 2 Seconds + + @JvmField + val DPAD_CONTROLS_HIDE_TIME: Long = 7000 // 7 Seconds + + @JvmField + val SEEK_OVERLAY_DURATION: Long = 450 // 450 millis + + // other constants (TODO remove playback speeds and use normal menu for popup, too) + private val PLAYBACK_SPEEDS = floatArrayOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f) + + private const val POPUP_MENU_ID_QUALITY = 69 + private const val POPUP_MENU_ID_AUDIO_TRACK = 70 + private const val POPUP_MENU_ID_PLAYBACK_SPEED = 79 + private const val POPUP_MENU_ID_CAPTION = 89 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt new file mode 100644 index 00000000000..e57ee746190 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.schabi.newpipe.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.util.AttributeSet +import org.schabi.newpipe.extractor.stream.StreamSegment + +/** + * A [FocusAwareSeekBar] that renders narrow transparent gaps at chapter boundaries, + * giving the seekbar a segmented "chopped" appearance. + * Call [setChapters] whenever a new stream loads. + */ +class ChaptersSeekBar : FocusAwareSeekBar { + + private val gapPaint = Paint().apply { + style = Paint.Style.FILL + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + + private var chapters: List = emptyList() + private var durationSeconds: Long = 0 + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + super(context, attrs, defStyleAttr) + + /** + * Stores chapter data for rendering segment gaps. + * + * @param newChapters list of [StreamSegment]s; may be empty but never null + * @param newDurationSecs total duration in seconds; used to compute fractional positions + */ + fun setChapters(newChapters: List, newDurationSecs: Long) { + chapters = newChapters + durationSeconds = newDurationSecs + invalidate() + } + + override fun onDraw(canvas: Canvas) { + if (chapters.isEmpty() || durationSeconds <= 0) { + super.onDraw(canvas) + return + } + + // Draw the seekbar into an offscreen layer so CLEAR mode can punch transparent gaps + val sc = canvas.saveLayer(null, null) + super.onDraw(canvas) + + val density = resources.displayMetrics.density + val gapHalfWidth = (GAP_WIDTH_DP * density) / 2f + val left = paddingLeft + val trackWidth = (width - left - paddingRight).toFloat() + + if (trackWidth > 0) { + for (seg in chapters) { + val startSec = seg.startTimeSeconds + // Skip the very first position and anything at or past the end + if (startSec <= 0 || startSec.toLong() >= durationSeconds) { + continue + } + val x = left + (startSec.toFloat() / durationSeconds.toFloat()) * trackWidth + canvas.drawRect(x - gapHalfWidth, 0f, x + gapHalfWidth, height.toFloat(), gapPaint) + } + } + + canvas.restoreToCount(sc) + + // Redraw the thumb on top so it visually overlaps the gaps + val t = thumb + if (t != null) { + val thumbSave = canvas.save() + canvas.translate((paddingLeft - thumbOffset).toFloat(), paddingTop.toFloat()) + t.draw(canvas) + canvas.restoreToCount(thumbSave) + } + } + + companion object { + private const val GAP_WIDTH_DP = 2f + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java deleted file mode 100644 index 8176a9aef70..00000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (C) Eltex ltd 2019 - * FocusAwareDrawerLayout.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ -package org.schabi.newpipe.views; - -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.KeyEvent; -import android.view.ViewTreeObserver; -import android.widget.SeekBar; - -import androidx.appcompat.widget.AppCompatSeekBar; - -import org.schabi.newpipe.util.DeviceUtils; - -/** - * SeekBar, adapted for directional navigation. It emulates touch-related callbacks - * (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to - * work with it. - */ -public final class FocusAwareSeekBar extends AppCompatSeekBar { - private NestedListener listener; - - private ViewTreeObserver treeObserver; - - public FocusAwareSeekBar(final Context context) { - super(context); - } - - public FocusAwareSeekBar(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - public FocusAwareSeekBar(final Context context, final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void setOnSeekBarChangeListener(final OnSeekBarChangeListener l) { - this.listener = l == null ? null : new NestedListener(l); - - super.setOnSeekBarChangeListener(listener); - } - - @Override - public boolean onKeyDown(final int keyCode, final KeyEvent event) { - if (!isInTouchMode() && DeviceUtils.isConfirmKey(keyCode)) { - releaseTrack(); - } - - return super.onKeyDown(keyCode, event); - } - - @Override - protected void onFocusChanged(final boolean gainFocus, final int direction, - final Rect previouslyFocusedRect) { - super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); - - if (!isInTouchMode() && !gainFocus) { - releaseTrack(); - } - } - - private final ViewTreeObserver.OnTouchModeChangeListener touchModeListener = isInTouchMode -> { - if (isInTouchMode) { - releaseTrack(); - } - }; - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - - treeObserver = getViewTreeObserver(); - treeObserver.addOnTouchModeChangeListener(touchModeListener); - } - - @Override - protected void onDetachedFromWindow() { - if (treeObserver == null || !treeObserver.isAlive()) { - treeObserver = getViewTreeObserver(); - } - - treeObserver.removeOnTouchModeChangeListener(touchModeListener); - treeObserver = null; - - super.onDetachedFromWindow(); - } - - private void releaseTrack() { - if (listener != null && listener.isSeeking) { - listener.onStopTrackingTouch(this); - } - } - - private static final class NestedListener implements OnSeekBarChangeListener { - private final OnSeekBarChangeListener delegate; - - boolean isSeeking; - - private NestedListener(final OnSeekBarChangeListener delegate) { - this.delegate = delegate; - } - - @Override - public void onProgressChanged(final SeekBar seekBar, final int progress, - final boolean fromUser) { - if (!seekBar.isInTouchMode() && !isSeeking && fromUser) { - isSeeking = true; - - onStartTrackingTouch(seekBar); - } - - delegate.onProgressChanged(seekBar, progress, fromUser); - } - - @Override - public void onStartTrackingTouch(final SeekBar seekBar) { - isSeeking = true; - - delegate.onStartTrackingTouch(seekBar); - } - - @Override - public void onStopTrackingTouch(final SeekBar seekBar) { - isSeeking = false; - - delegate.onStopTrackingTouch(seekBar); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt new file mode 100644 index 00000000000..3f31bd8bae0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.schabi.newpipe.views + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.ViewTreeObserver +import android.widget.SeekBar +import androidx.appcompat.widget.AppCompatSeekBar +import org.schabi.newpipe.util.DeviceUtils + +/** + * SeekBar, adapted for directional navigation. It emulates touch-related callbacks + * (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to + * work with it. + */ +open class FocusAwareSeekBar : AppCompatSeekBar { + + private var listener: NestedListener? = null + private var treeObserver: ViewTreeObserver? = null + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + super(context, attrs, defStyleAttr) + + override fun setOnSeekBarChangeListener(l: OnSeekBarChangeListener?) { + listener = if (l == null) null else NestedListener(l) + super.setOnSeekBarChangeListener(listener) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (!isInTouchMode && DeviceUtils.isConfirmKey(keyCode)) { + releaseTrack() + } + return super.onKeyDown(keyCode, event) + } + + override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + if (!isInTouchMode && !gainFocus) { + releaseTrack() + } + } + + private val touchModeListener = ViewTreeObserver.OnTouchModeChangeListener { inTouchMode -> + if (inTouchMode) { + releaseTrack() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + treeObserver = viewTreeObserver + treeObserver?.addOnTouchModeChangeListener(touchModeListener) + } + + override fun onDetachedFromWindow() { + if (treeObserver?.isAlive != true) { + treeObserver = viewTreeObserver + } + treeObserver?.removeOnTouchModeChangeListener(touchModeListener) + treeObserver = null + super.onDetachedFromWindow() + } + + private fun releaseTrack() { + val l = listener + if (l != null && l.isSeeking) { + l.onStopTrackingTouch(this) + } + } + + private class NestedListener(private val delegate: OnSeekBarChangeListener) : + OnSeekBarChangeListener { + + var isSeeking = false + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (!seekBar.isInTouchMode && !isSeeking && fromUser) { + isSeeking = true + onStartTrackingTouch(seekBar) + } + delegate.onProgressChanged(seekBar, progress, fromUser) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + isSeeking = true + delegate.onStartTrackingTouch(seekBar) + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + isSeeking = false + delegate.onStopTrackingTouch(seekBar) + } + } +} diff --git a/app/src/main/res/layout/player.xml b/app/src/main/res/layout/player.xml index 94c16ad702f..d2632261e77 100644 --- a/app/src/main/res/layout/player.xml +++ b/app/src/main/res/layout/player.xml @@ -415,6 +415,23 @@ android:orientation="vertical" android:paddingBottom="12dp"> + + -