From 8d9c573970d97a7b5950bca42cf5161db70882b6 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 11 Apr 2026 20:06:33 +0200 Subject: [PATCH 01/12] Add chapter markers support to seekbar and update UI elements --- .../newpipe/player/ui/VideoPlayerUi.java | 54 +++++++++++++++++++ .../newpipe/views/FocusAwareSeekBar.java | 2 +- app/src/main/res/layout/player.xml | 19 ++++++- 3 files changed, 73 insertions(+), 2 deletions(-) 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 index 263bc71a262..6a1daffc7b0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java @@ -66,7 +66,9 @@ 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.views.ChaptersSeekBar; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.player.Player; @@ -86,6 +88,7 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -148,6 +151,9 @@ private enum PlayButtonAction { private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = new SeekbarPreviewThumbnailHolder(); + @NonNull + private List currentChapters = Collections.emptyList(); + /*////////////////////////////////////////////////////////////////////////// // Constructor, setup, destroy @@ -586,6 +592,14 @@ public void onProgressChanged(final SeekBar seekBar, final int progress, binding.currentSeekbarPreviewThumbnail, binding.subtitleView::getWidth); + // Chapter title tooltip + if (!currentChapters.isEmpty()) { + final StreamSegment chapter = getChapterAtMs(progress); + if (chapter != null && chapter.getTitle() != null) { + binding.currentChapterTitle.setText(chapter.getTitle()); + } + } + adjustSeekbarPreviewContainer(); } @@ -639,6 +653,10 @@ public void onStartTrackingTouch(final SeekBar seekBar) { AnimationType.SCALE_AND_ALPHA); animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA); + if (!currentChapters.isEmpty()) { + animate(binding.currentChapterTitle, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SCALE_AND_ALPHA); + } } @Override // seekbar listener @@ -655,6 +673,7 @@ public void onStopTrackingTouch(final SeekBar seekBar) { binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA); + animate(binding.currentChapterTitle, false, 200, AnimationType.SCALE_AND_ALPHA); if (player.getCurrentState() == STATE_PAUSED_SEEK) { player.changeState(STATE_BUFFERING); @@ -665,6 +684,25 @@ public void onStopTrackingTouch(final SeekBar seekBar) { showControlsThenHide(); } + + /** + * Returns the chapter active at the given playback position, or {@code null} if + * {@code currentChapters} is empty. + * + * @param positionMs playback position in milliseconds + * @return the {@link StreamSegment} whose window contains {@code positionMs} + */ + @Nullable + private StreamSegment getChapterAtMs(final long positionMs) { + StreamSegment result = null; + for (final StreamSegment seg : currentChapters) { + if (seg.getStartTimeSeconds() * 1000L > positionMs) { + break; + } + result = seg; + } + return result; + } //endregion @@ -1023,6 +1061,22 @@ public void onMetadataChanged(@NonNull final StreamInfo info) { binding.channelTextView.setText(info.getUploaderName()); this.seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames()); + + // Chapter markers on seekbar + currentChapters = info.getStreamSegments() != null + ? info.getStreamSegments() : Collections.emptyList(); + Log.d(TAG, "onMetadataChanged: seekBarClass=" + + binding.playbackSeekBar.getClass().getSimpleName() + + " segments=" + currentChapters.size() + + " duration=" + info.getDuration()); + if (binding.playbackSeekBar instanceof ChaptersSeekBar) { + ((ChaptersSeekBar) binding.playbackSeekBar) + .setChapters(currentChapters, info.getDuration()); + } else { + Log.e(TAG, "onMetadataChanged: playbackSeekBar is NOT a ChaptersSeekBar! " + + "Check that player.xml was rebuilt."); + } + binding.currentChapterTitle.setVisibility(View.GONE); } private void updateStreamRelatedViews() { diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java index 8176a9aef70..651d2f37326 100644 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java @@ -33,7 +33,7 @@ * (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to * work with it. */ -public final class FocusAwareSeekBar extends AppCompatSeekBar { +public class FocusAwareSeekBar extends AppCompatSeekBar { private NestedListener listener; private ViewTreeObserver treeObserver; 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"> + + - Date: Sun, 12 Apr 2026 11:01:38 +0200 Subject: [PATCH 02/12] Add ChaptersSeekBar to render chapter markers on the seekbar --- .../schabi/newpipe/views/ChaptersSeekBar.java | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java diff --git a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java new file mode 100644 index 00000000000..a67b267d1a5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) NewPipe Contributors + * ChaptersSeekBar.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.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.stream.StreamSegment; + +import java.util.Collections; +import java.util.List; + +/** + * A {@link FocusAwareSeekBar} that draws thin white vertical tick marks at chapter boundaries. + * Call {@link #setChapters(List, long)} whenever a new stream loads. + */ +public final class ChaptersSeekBar extends FocusAwareSeekBar { + + private static final String TAG = "ChaptersSeekBar"; + + private static final int TICK_ALPHA = 180; // ~70% opacity + private static final float TICK_WIDTH_DP = 2f; + private static final float TICK_HEIGHT_FRACTION = 0.6f; // fraction of view height + + private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + @NonNull private List chapters = Collections.emptyList(); + private long durationSeconds = 0; + + public ChaptersSeekBar(@NonNull final Context context) { + super(context); + init(); + } + + public ChaptersSeekBar(@NonNull final Context context, + @Nullable final AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ChaptersSeekBar(@NonNull final Context context, + @Nullable final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + tickPaint.setColor(Color.WHITE); + tickPaint.setAlpha(TICK_ALPHA); + tickPaint.setStyle(Paint.Style.FILL); + Log.d(TAG, "init: ChaptersSeekBar created"); + } + + /** + * Stores chapter data for rendering tick marks. + * + * @param newChapters list of {@link StreamSegment}s; may be empty but never null + * @param newDurationSecs total duration in seconds; used to compute fractional positions + */ + public void setChapters(@NonNull final List newChapters, + final long newDurationSecs) { + chapters = newChapters; + durationSeconds = newDurationSecs; + Log.d(TAG, "setChapters: count=" + newChapters.size() + + " durationSeconds=" + newDurationSecs); + for (final StreamSegment seg : newChapters) { + Log.d(TAG, " chapter: startSec=" + seg.getStartTimeSeconds() + + " title=" + seg.getTitle()); + } + invalidate(); + } + + @Override + protected void onDraw(@NonNull final Canvas canvas) { + super.onDraw(canvas); + + if (chapters.isEmpty() || durationSeconds <= 0) { + Log.d(TAG, "onDraw: skipped — chapters=" + chapters.size() + + " durationSeconds=" + durationSeconds); + return; + } + + final float density = getResources().getDisplayMetrics().density; + final float tickWidthPx = TICK_WIDTH_DP * density; + + // Track bounds: AbsSeekBar pads the track by getPaddingLeft/getPaddingRight + final int paddingLeft = getPaddingLeft(); + final int paddingRight = getPaddingRight(); + final float trackWidth = getWidth() - paddingLeft - paddingRight; + + Log.d(TAG, "onDraw: w=" + getWidth() + " h=" + getHeight() + + " paddingL=" + paddingLeft + " paddingR=" + paddingRight + + " trackWidth=" + trackWidth + " chapters=" + chapters.size() + + " durationSeconds=" + durationSeconds); + + if (trackWidth <= 0) { + Log.d(TAG, "onDraw: trackWidth<=0, skipping"); + return; + } + + // Center ticks vertically, scaling height as a fraction of the view + final float tickHeight = getHeight() * TICK_HEIGHT_FRACTION; + final float tickTop = (getHeight() - tickHeight) / 2f; + final float tickBottom = tickTop + tickHeight; + + for (final StreamSegment seg : chapters) { + final int startSec = seg.getStartTimeSeconds(); + // Skip the very beginning and anything at or past the end + if (startSec <= 0 || startSec >= durationSeconds) { + Log.d(TAG, " skipping seg startSec=" + startSec); + continue; + } + final float x = paddingLeft + (startSec / (float) durationSeconds) * trackWidth; + Log.d(TAG, " drawing tick at x=" + x + " for startSec=" + startSec + + " title=" + seg.getTitle()); + canvas.drawRect( + x - tickWidthPx / 2f, + tickTop, + x + tickWidthPx / 2f, + tickBottom, + tickPaint); + } + } +} From d8c20b8bbb522794ab0e1c820a7a30875fbdb93e Mon Sep 17 00:00:00 2001 From: h Date: Mon, 13 Apr 2026 05:52:55 +0200 Subject: [PATCH 03/12] Enhance ChaptersSeekBar to render transparent gaps at chapter boundaries and add haptic feedback for chapter navigation --- .../newpipe/player/ui/VideoPlayerUi.java | 23 +++-- .../schabi/newpipe/views/ChaptersSeekBar.java | 98 +++++++------------ 2 files changed, 49 insertions(+), 72 deletions(-) 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 index 6a1daffc7b0..d8b79809809 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java @@ -28,6 +28,7 @@ import android.os.Looper; import android.util.Log; import android.view.GestureDetector; +import android.view.HapticFeedbackConstants; import android.view.Gravity; import android.view.KeyEvent; import android.view.Menu; @@ -153,6 +154,8 @@ private enum PlayButtonAction { @NonNull private List currentChapters = Collections.emptyList(); + @Nullable + private StreamSegment lastChapterForHaptic = null; /*////////////////////////////////////////////////////////////////////////// @@ -592,12 +595,16 @@ public void onProgressChanged(final SeekBar seekBar, final int progress, binding.currentSeekbarPreviewThumbnail, binding.subtitleView::getWidth); - // Chapter title tooltip + // Chapter title tooltip + haptic feedback at chapter boundaries if (!currentChapters.isEmpty()) { final StreamSegment chapter = getChapterAtMs(progress); if (chapter != null && chapter.getTitle() != null) { binding.currentChapterTitle.setText(chapter.getTitle()); } + if (chapter != lastChapterForHaptic) { + lastChapterForHaptic = chapter; + seekBar.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); + } } adjustSeekbarPreviewContainer(); @@ -1065,17 +1072,9 @@ public void onMetadataChanged(@NonNull final StreamInfo info) { // Chapter markers on seekbar currentChapters = info.getStreamSegments() != null ? info.getStreamSegments() : Collections.emptyList(); - Log.d(TAG, "onMetadataChanged: seekBarClass=" - + binding.playbackSeekBar.getClass().getSimpleName() - + " segments=" + currentChapters.size() - + " duration=" + info.getDuration()); - if (binding.playbackSeekBar instanceof ChaptersSeekBar) { - ((ChaptersSeekBar) binding.playbackSeekBar) - .setChapters(currentChapters, info.getDuration()); - } else { - Log.e(TAG, "onMetadataChanged: playbackSeekBar is NOT a ChaptersSeekBar! " - + "Check that player.xml was rebuilt."); - } + lastChapterForHaptic = null; + ((ChaptersSeekBar) binding.playbackSeekBar) + .setChapters(currentChapters, info.getDuration()); binding.currentChapterTitle.setVisibility(View.GONE); } diff --git a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java index a67b267d1a5..20d2e57a38b 100644 --- a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java +++ b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java @@ -19,10 +19,11 @@ import android.content.Context; import android.graphics.Canvas; -import android.graphics.Color; import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.drawable.Drawable; import android.util.AttributeSet; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -33,18 +34,15 @@ import java.util.List; /** - * A {@link FocusAwareSeekBar} that draws thin white vertical tick marks at chapter boundaries. + * A {@link FocusAwareSeekBar} that renders narrow transparent gaps at chapter boundaries, + * giving the seekbar a segmented "chopped" appearance. * Call {@link #setChapters(List, long)} whenever a new stream loads. */ public final class ChaptersSeekBar extends FocusAwareSeekBar { - private static final String TAG = "ChaptersSeekBar"; + private static final float GAP_WIDTH_DP = 2f; - private static final int TICK_ALPHA = 180; // ~70% opacity - private static final float TICK_WIDTH_DP = 2f; - private static final float TICK_HEIGHT_FRACTION = 0.6f; // fraction of view height - - private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint gapPaint = new Paint(); @NonNull private List chapters = Collections.emptyList(); private long durationSeconds = 0; @@ -68,14 +66,12 @@ public ChaptersSeekBar(@NonNull final Context context, } private void init() { - tickPaint.setColor(Color.WHITE); - tickPaint.setAlpha(TICK_ALPHA); - tickPaint.setStyle(Paint.Style.FILL); - Log.d(TAG, "init: ChaptersSeekBar created"); + gapPaint.setStyle(Paint.Style.FILL); + gapPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); } /** - * Stores chapter data for rendering tick marks. + * Stores chapter data for rendering segment gaps. * * @param newChapters list of {@link StreamSegment}s; may be empty but never null * @param newDurationSecs total duration in seconds; used to compute fractional positions @@ -84,64 +80,46 @@ public void setChapters(@NonNull final List newChapters, final long newDurationSecs) { chapters = newChapters; durationSeconds = newDurationSecs; - Log.d(TAG, "setChapters: count=" + newChapters.size() - + " durationSeconds=" + newDurationSecs); - for (final StreamSegment seg : newChapters) { - Log.d(TAG, " chapter: startSec=" + seg.getStartTimeSeconds() - + " title=" + seg.getTitle()); - } invalidate(); } @Override protected void onDraw(@NonNull final Canvas canvas) { - super.onDraw(canvas); - if (chapters.isEmpty() || durationSeconds <= 0) { - Log.d(TAG, "onDraw: skipped — chapters=" + chapters.size() - + " durationSeconds=" + durationSeconds); + super.onDraw(canvas); return; } - final float density = getResources().getDisplayMetrics().density; - final float tickWidthPx = TICK_WIDTH_DP * density; - - // Track bounds: AbsSeekBar pads the track by getPaddingLeft/getPaddingRight - final int paddingLeft = getPaddingLeft(); - final int paddingRight = getPaddingRight(); - final float trackWidth = getWidth() - paddingLeft - paddingRight; - - Log.d(TAG, "onDraw: w=" + getWidth() + " h=" + getHeight() - + " paddingL=" + paddingLeft + " paddingR=" + paddingRight - + " trackWidth=" + trackWidth + " chapters=" + chapters.size() - + " durationSeconds=" + durationSeconds); + // Draw the seekbar into an offscreen layer so CLEAR mode can punch transparent gaps + final int sc = canvas.saveLayer(null, null); + super.onDraw(canvas); - if (trackWidth <= 0) { - Log.d(TAG, "onDraw: trackWidth<=0, skipping"); - return; + final float density = getResources().getDisplayMetrics().density; + final float gapHalfWidth = (GAP_WIDTH_DP * density) / 2f; + final int paddingLeft = getPaddingLeft(); + final float trackWidth = getWidth() - paddingLeft - getPaddingRight(); + + if (trackWidth > 0) { + for (final StreamSegment seg : chapters) { + final int startSec = seg.getStartTimeSeconds(); + // Skip the very first position and anything at or past the end + if (startSec <= 0 || startSec >= durationSeconds) { + continue; + } + final float x = paddingLeft + (startSec / (float) durationSeconds) * trackWidth; + canvas.drawRect(x - gapHalfWidth, 0, x + gapHalfWidth, getHeight(), gapPaint); + } } - // Center ticks vertically, scaling height as a fraction of the view - final float tickHeight = getHeight() * TICK_HEIGHT_FRACTION; - final float tickTop = (getHeight() - tickHeight) / 2f; - final float tickBottom = tickTop + tickHeight; - - for (final StreamSegment seg : chapters) { - final int startSec = seg.getStartTimeSeconds(); - // Skip the very beginning and anything at or past the end - if (startSec <= 0 || startSec >= durationSeconds) { - Log.d(TAG, " skipping seg startSec=" + startSec); - continue; - } - final float x = paddingLeft + (startSec / (float) durationSeconds) * trackWidth; - Log.d(TAG, " drawing tick at x=" + x + " for startSec=" + startSec - + " title=" + seg.getTitle()); - canvas.drawRect( - x - tickWidthPx / 2f, - tickTop, - x + tickWidthPx / 2f, - tickBottom, - tickPaint); + canvas.restoreToCount(sc); + + // Redraw the thumb on top so it visually overlaps the gaps + final Drawable thumb = getThumb(); + if (thumb != null) { + final int thumbSave = canvas.save(); + canvas.translate(getPaddingLeft() - getThumbOffset(), getPaddingTop()); + thumb.draw(canvas); + canvas.restoreToCount(thumbSave); } } } From 51e5233652fd62324a3435bb9484a671563907d2 Mon Sep 17 00:00:00 2001 From: h Date: Thu, 16 Apr 2026 08:05:16 +0200 Subject: [PATCH 04/12] Refactor code --- .../newpipe/player/ui/VideoPlayerUi.java | 1682 ----------------- .../schabi/newpipe/player/ui/VideoPlayerUi.kt | 1641 ++++++++++++++++ 2 files changed, 1641 insertions(+), 1682 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt 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 d8b79809809..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ /dev/null @@ -1,1682 +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.HapticFeedbackConstants; -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.StreamSegment; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.views.ChaptersSeekBar; -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.Collections; -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(); - - @NonNull - private List currentChapters = Collections.emptyList(); - @Nullable - private StreamSegment lastChapterForHaptic = null; - - - /*////////////////////////////////////////////////////////////////////////// - // 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); - - // Chapter title tooltip + haptic feedback at chapter boundaries - if (!currentChapters.isEmpty()) { - final StreamSegment chapter = getChapterAtMs(progress); - if (chapter != null && chapter.getTitle() != null) { - binding.currentChapterTitle.setText(chapter.getTitle()); - } - if (chapter != lastChapterForHaptic) { - lastChapterForHaptic = chapter; - seekBar.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); - } - } - - 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); - if (!currentChapters.isEmpty()) { - animate(binding.currentChapterTitle, 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); - animate(binding.currentChapterTitle, false, 200, AnimationType.SCALE_AND_ALPHA); - - if (player.getCurrentState() == STATE_PAUSED_SEEK) { - player.changeState(STATE_BUFFERING); - } - if (!player.isProgressLoopRunning()) { - player.startProgressLoop(); - } - - showControlsThenHide(); - } - - /** - * Returns the chapter active at the given playback position, or {@code null} if - * {@code currentChapters} is empty. - * - * @param positionMs playback position in milliseconds - * @return the {@link StreamSegment} whose window contains {@code positionMs} - */ - @Nullable - private StreamSegment getChapterAtMs(final long positionMs) { - StreamSegment result = null; - for (final StreamSegment seg : currentChapters) { - if (seg.getStartTimeSeconds() * 1000L > positionMs) { - break; - } - result = seg; - } - return result; - } - //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()); - - // Chapter markers on seekbar - currentChapters = info.getStreamSegments() != null - ? info.getStreamSegments() : Collections.emptyList(); - lastChapterForHaptic = null; - ((ChaptersSeekBar) binding.playbackSeekBar) - .setChapters(currentChapters, info.getDuration()); - binding.currentChapterTitle.setVisibility(View.GONE); - } - - 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..fab33aab123 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt @@ -0,0 +1,1641 @@ +package org.schabi.newpipe.player.ui + +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.annotation.OptIn +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 + protected 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 + protected 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 + + private 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 + } + + internal 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.fullScreenButton.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.fullScreenButton.setOnClickListener(null) + binding.screenRotationButton.setOnClickListener(null) + binding.playWithKodi.setOnClickListener(null) + binding.openInBrowser.setOnClickListener(null) + binding.playerCloseButton.setOnClickListener(null) + binding.switchMute.setOnClickListener(null) + + ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, null) + + binding.playbackControlRoot.removeOnLayoutChangeListener(onLayoutChangeListener) + } + + /** + * Initializes the Fast-For/Backward overlay. + */ + private 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) + } + + 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 (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) + } + + /** + * 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) + + 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) + + // 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) + 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 + + fun isControlsVisible(): Boolean = 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.streams.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 fun isFullscreen(): Boolean { + // only MainPlayerUi can be in fullscreen, so overridden there + return 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) + } + + 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.streams.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() + } + + 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) + } + + 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 + currentChapters = info.streamSegments ?: emptyList() + lastChapterForHaptic = null + (binding.playbackSeekBar as ChaptersSeekBar) + .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]) + ) + } + binding.playbackSpeed.text = formatSpeed(player.playbackSpeed) + 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) + 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 + } + + fun isSomePopupMenuVisible(): Boolean = isSomePopupMenuVisible + // 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 = currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } + val availableLanguages = textTracks + .map { it.mediaTrackGroup } + .filter { it.length > 0 } + .map { it.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.screenRotationButton && 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(@AspectRatioFrameLayout.ResizeMode 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 + + fun getGestureDetector(): GestureDetector? = gestureDetector + // 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: Int = 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 + } +} From 67302fb732bbcab9e17d61d28d2fe1f6accab471 Mon Sep 17 00:00:00 2001 From: h Date: Thu, 16 Apr 2026 08:11:16 +0200 Subject: [PATCH 05/12] Fix null pointer exception by safely accessing gestureDetector in BasePlayerGestureListener --- .../schabi/newpipe/player/gesture/BasePlayerGestureListener.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 } From de7a284f438be02e007c1760cab65b34dbdb5c43 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 17 Apr 2026 06:38:40 +0200 Subject: [PATCH 06/12] Refactor and migrate ChaptersSeekBar and FocusAwareSeekBar to Kotlin --- .../schabi/newpipe/views/ChaptersSeekBar.java | 125 --------------- .../schabi/newpipe/views/ChaptersSeekBar.kt | 102 ++++++++++++ .../newpipe/views/FocusAwareSeekBar.java | 147 ------------------ .../schabi/newpipe/views/FocusAwareSeekBar.kt | 114 ++++++++++++++ 4 files changed, 216 insertions(+), 272 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt diff --git a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java deleted file mode 100644 index 20d2e57a38b..00000000000 --- a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) NewPipe Contributors - * ChaptersSeekBar.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.Canvas; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.drawable.Drawable; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.stream.StreamSegment; - -import java.util.Collections; -import java.util.List; - -/** - * A {@link FocusAwareSeekBar} that renders narrow transparent gaps at chapter boundaries, - * giving the seekbar a segmented "chopped" appearance. - * Call {@link #setChapters(List, long)} whenever a new stream loads. - */ -public final class ChaptersSeekBar extends FocusAwareSeekBar { - - private static final float GAP_WIDTH_DP = 2f; - - private final Paint gapPaint = new Paint(); - - @NonNull private List chapters = Collections.emptyList(); - private long durationSeconds = 0; - - public ChaptersSeekBar(@NonNull final Context context) { - super(context); - init(); - } - - public ChaptersSeekBar(@NonNull final Context context, - @Nullable final AttributeSet attrs) { - super(context, attrs); - init(); - } - - public ChaptersSeekBar(@NonNull final Context context, - @Nullable final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - gapPaint.setStyle(Paint.Style.FILL); - gapPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - } - - /** - * Stores chapter data for rendering segment gaps. - * - * @param newChapters list of {@link StreamSegment}s; may be empty but never null - * @param newDurationSecs total duration in seconds; used to compute fractional positions - */ - public void setChapters(@NonNull final List newChapters, - final long newDurationSecs) { - chapters = newChapters; - durationSeconds = newDurationSecs; - invalidate(); - } - - @Override - protected void onDraw(@NonNull final 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 - final int sc = canvas.saveLayer(null, null); - super.onDraw(canvas); - - final float density = getResources().getDisplayMetrics().density; - final float gapHalfWidth = (GAP_WIDTH_DP * density) / 2f; - final int paddingLeft = getPaddingLeft(); - final float trackWidth = getWidth() - paddingLeft - getPaddingRight(); - - if (trackWidth > 0) { - for (final StreamSegment seg : chapters) { - final int startSec = seg.getStartTimeSeconds(); - // Skip the very first position and anything at or past the end - if (startSec <= 0 || startSec >= durationSeconds) { - continue; - } - final float x = paddingLeft + (startSec / (float) durationSeconds) * trackWidth; - canvas.drawRect(x - gapHalfWidth, 0, x + gapHalfWidth, getHeight(), gapPaint); - } - } - - canvas.restoreToCount(sc); - - // Redraw the thumb on top so it visually overlaps the gaps - final Drawable thumb = getThumb(); - if (thumb != null) { - final int thumbSave = canvas.save(); - canvas.translate(getPaddingLeft() - getThumbOffset(), getPaddingTop()); - thumb.draw(canvas); - canvas.restoreToCount(thumbSave); - } - } -} 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..8137c4fc84c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) NewPipe Contributors + * ChaptersSeekBar.kt 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.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 651d2f37326..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 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..c51ab8a02e5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) Eltex ltd 2019 + * FocusAwareSeekBar.kt 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. + */ +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) + } + } +} From f8c2b827e0fd7c9790d17bf8d0b9b7d62c338d39 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 17 Apr 2026 06:43:10 +0200 Subject: [PATCH 07/12] Change buildGestureListener visibility to protected for Java subclass access --- .../java/org/schabi/newpipe/player/ui/MainPlayerUi.java | 6 +++++- .../java/org/schabi/newpipe/player/ui/PopupPlayerUi.java | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) 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..71849434417 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 @@ -146,8 +146,12 @@ public void setupAfterIntent() { } } + // `protected` matches the visibility declared in VideoPlayerUi.kt. The Kotlin parent used to + // declare this as `internal`, which the Kotlin compiler mangles to a JVM-private name that + // Java cannot see, so this class could not compile. Changing to `protected` gives the method + // a stable, unmangled JVM name accessible from Java subclasses. @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..907fc1fecad 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 @@ -101,8 +101,11 @@ public void setupAfterIntent() { initPopupCloseOverlay(); } + // Same reason as MainPlayerUi: `protected` is required so this Java class can override the + // method declared in the Kotlin parent (VideoPlayerUi). Using `internal` in Kotlin produces + // a mangled JVM name that Java subclasses cannot reference. @Override - BasePlayerGestureListener buildGestureListener() { + protected BasePlayerGestureListener buildGestureListener() { return new PopupPlayerGestureListener(this); } From d9502019f9a3ee735c2ef178b63e93828ed93134 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 17 Apr 2026 06:45:48 +0200 Subject: [PATCH 08/12] Refactor VideoPlayerUi to improve accessibility for Java subclasses and enhance chapter marker handling --- .../schabi/newpipe/player/ui/VideoPlayerUi.kt | 98 ++++++++++++------- 1 file changed, 62 insertions(+), 36 deletions(-) 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 index fab33aab123..567760ea531 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt @@ -20,7 +20,6 @@ import android.view.View import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.SeekBar -import androidx.annotation.OptIn import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.AppCompatImageButton @@ -101,7 +100,7 @@ abstract class VideoPlayerUi protected constructor( // region Views @JvmField - protected var binding: PlayerBinding = playerBinding + var binding: PlayerBinding = playerBinding private val controlsVisibilityHandler = Handler(Looper.getMainLooper()) @@ -113,7 +112,7 @@ abstract class VideoPlayerUi protected constructor( // Popup menus ("popup" means that they pop up, not that they belong to the popup player) @JvmField - protected var isSomePopupMenuVisible = false + var isSomePopupMenuVisible = false private var qualityPopupMenu: PopupMenu? = null private var audioTrackPopupMenu: PopupMenu? = null @@ -125,7 +124,7 @@ abstract class VideoPlayerUi protected constructor( // Gestures - private var gestureDetector: GestureDetector? = null + internal var gestureDetector: GestureDetector? = null private var playerGestureListener: BasePlayerGestureListener? = null private var onLayoutChangeListener: View.OnLayoutChangeListener? = null @@ -177,7 +176,11 @@ abstract class VideoPlayerUi protected constructor( binding.itemsList.isNestedScrollingEnabled = false } - internal abstract fun buildGestureListener(): BasePlayerGestureListener + // 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)) @@ -192,7 +195,7 @@ abstract class VideoPlayerUi protected constructor( binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault)) playerGestureListener = buildGestureListener() - gestureDetector = GestureDetector(context, playerGestureListener) + gestureDetector = GestureDetector(context, playerGestureListener!!) binding.root.setOnTouchListener(playerGestureListener) binding.repeatButton.setOnClickListener { onRepeatClicked() } @@ -392,6 +395,14 @@ abstract class VideoPlayerUi protected constructor( // #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() @@ -497,6 +508,20 @@ abstract class VideoPlayerUi protected constructor( 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) } @@ -504,11 +529,12 @@ abstract class VideoPlayerUi protected constructor( 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()}, " + + "isVisible = $isControlsVisible, " + "currentProgress = [$currentProgress], " + "duration = [$duration], bufferPercent = [$bufferPercent]" ) @@ -526,7 +552,7 @@ abstract class VideoPlayerUi protected constructor( if (player.currentState != STATE_PAUSED_SEEK) { binding.playbackSeekBar.progress = currentProgress } - binding.playbackCurrentTime.text = getTimeString(currentProgress) + binding.playbackCurrentTime.text = getTimeString(currentProgress.toLong()) } /** @@ -535,7 +561,7 @@ abstract class VideoPlayerUi protected constructor( * @param duration the video duration, in milliseconds */ private fun setVideoDurationToControls(duration: Int) { - binding.playbackEndTime.text = getTimeString(duration) + binding.playbackEndTime.text = getTimeString(duration.toLong()) binding.playbackSeekBar.max = duration // This is important for Android TVs otherwise it would apply the default from @@ -558,7 +584,7 @@ abstract class VideoPlayerUi protected constructor( ) } - binding.currentDisplaySeek.text = getTimeString(progress) + binding.currentDisplaySeek.text = getTimeString(progress.toLong()) // Seekbar Preview Thumbnail SeekbarPreviewThumbnailHelper @@ -645,7 +671,7 @@ abstract class VideoPlayerUi protected constructor( player.exoPlayer.play() } - binding.playbackCurrentTime.text = getTimeString(seekBar.progress) + 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) @@ -681,7 +707,8 @@ abstract class VideoPlayerUi protected constructor( // region Controls showing / hiding - fun isControlsVisible(): Boolean = binding.playbackControlRoot.visibility == View.VISIBLE + val isControlsVisible: Boolean + get() = binding.playbackControlRoot.visibility == View.VISIBLE fun showControlsThenHide() { if (DEBUG) { @@ -772,10 +799,9 @@ abstract class VideoPlayerUi protected constructor( return false } - open fun isFullscreen(): Boolean { + open val isFullscreen: Boolean // only MainPlayerUi can be in fullscreen, so overridden there - return false - } + get() = false /** * Update the play/pause button to reflect the action that will be performed when clicked. @@ -807,7 +833,7 @@ abstract class VideoPlayerUi protected constructor( override fun onPrepared() { super.onPrepared() setVideoDurationToControls(player.exoPlayer.duration.toInt()) - binding.playbackSpeed.text = formatSpeed(player.playbackSpeed) + binding.playbackSpeed.text = formatSpeed(player.playbackSpeed.toDouble()) } override fun onBlocked() { @@ -865,7 +891,7 @@ abstract class VideoPlayerUi protected constructor( // 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()) { + if (!playerGestureListener!!.isDoubleTapping) { showControls(400) binding.loadingPanel.visibility = View.GONE @@ -982,7 +1008,7 @@ abstract class VideoPlayerUi protected constructor( override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { super.onPlaybackParametersChanged(playbackParameters) - binding.playbackSpeed.text = formatSpeed(playbackParameters.speed) + binding.playbackSpeed.text = formatSpeed(playbackParameters.speed.toDouble()) } override fun onRenderedFirstFrame() { @@ -1005,10 +1031,13 @@ abstract class VideoPlayerUi protected constructor( seekbarPreviewThumbnailHolder.resetFrom(player.context, info.previewFrames) // Chapter markers on seekbar - currentChapters = info.streamSegments ?: emptyList() + val rawSegments = info.streamSegments + currentChapters = rawSegments ?: emptyList() lastChapterForHaptic = null - (binding.playbackSeekBar as ChaptersSeekBar) - .setChapters(currentChapters, info.duration) + val seekBar = binding.playbackSeekBar + if (seekBar is ChaptersSeekBar) { + seekBar.setChapters(currentChapters, info.duration) + } binding.currentChapterTitle.visibility = View.GONE } @@ -1134,10 +1163,10 @@ abstract class VideoPlayerUi protected constructor( POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE, - formatSpeed(PLAYBACK_SPEEDS[i]) + formatSpeed(PLAYBACK_SPEEDS[i].toDouble()) ) } - binding.playbackSpeed.text = formatSpeed(player.playbackSpeed) + binding.playbackSpeed.text = formatSpeed(player.playbackSpeed.toDouble()) playbackSpeedPopupMenu!!.setOnMenuItemClickListener(this) playbackSpeedPopupMenu!!.setOnDismissListener(this) } @@ -1287,7 +1316,7 @@ abstract class VideoPlayerUi protected constructor( val speedIndex = menuItem.itemId val speed = PLAYBACK_SPEEDS[speedIndex] player.setPlaybackSpeed(speed) - binding.playbackSpeed.text = formatSpeed(speed) + binding.playbackSpeed.text = formatSpeed(speed.toDouble()) false } @@ -1360,7 +1389,6 @@ abstract class VideoPlayerUi protected constructor( isSomePopupMenuVisible = true } - fun isSomePopupMenuVisible(): Boolean = isSomePopupMenuVisible // endregion // region Captions (text tracks) @@ -1376,11 +1404,11 @@ abstract class VideoPlayerUi protected constructor( } // Extract all loaded languages - val textTracks = currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } + val textTracks: List = currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT } val availableLanguages = textTracks - .map { it.mediaTrackGroup } - .filter { it.length > 0 } - .map { it.getFormat(0).language } + .mapNotNull { group -> + group.mediaTrackGroup.takeIf { it.length > 0 }?.getFormat(0)?.language + } // Find selected text track val selectedTrack: Format? = textTracks @@ -1449,7 +1477,7 @@ abstract class VideoPlayerUi protected constructor( if (player.currentState == STATE_PLAYING && !isSomePopupMenuVisible) { if (v == binding.playPauseButton || // Hide controls in fullscreen immediately - (v == binding.screenRotationButton && isFullscreen()) + (v == binding.screenRotationButton && isFullscreen) ) { hideControls(0, 0) } else { @@ -1463,7 +1491,7 @@ abstract class VideoPlayerUi protected constructor( open fun onKeyDown(keyCode: Int): Boolean { when (keyCode) { KeyEvent.KEYCODE_BACK -> { - if (DeviceUtils.isTv(context) && isControlsVisible()) { + if (DeviceUtils.isTv(context) && isControlsVisible) { hideControls(0, 0) return true } @@ -1485,7 +1513,7 @@ abstract class VideoPlayerUi protected constructor( return true } - if (isControlsVisible()) { + if (isControlsVisible) { hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME) } else { binding.playPauseButton.requestFocus() @@ -1544,7 +1572,7 @@ abstract class VideoPlayerUi protected constructor( // region Video size - protected fun setResizeMode(@AspectRatioFrameLayout.ResizeMode resizeMode: Int) { + protected fun setResizeMode(resizeMode: Int) { binding.surfaceView.setResizeMode(resizeMode) binding.resizeTextView.text = PlayerHelper.resizeTypeOf(context, resizeMode) } @@ -1610,8 +1638,6 @@ abstract class VideoPlayerUi protected constructor( // region Getters fun getBinding(): PlayerBinding = binding - - fun getGestureDetector(): GestureDetector? = gestureDetector // endregion companion object { @@ -1628,7 +1654,7 @@ abstract class VideoPlayerUi protected constructor( val DPAD_CONTROLS_HIDE_TIME: Long = 7000 // 7 Seconds @JvmField - val SEEK_OVERLAY_DURATION: Int = 450 // 450 millis + 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) From c567e05d772c7cd4e25d5c18d17767c45e0a8008 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 17 Apr 2026 06:51:11 +0200 Subject: [PATCH 09/12] Update copyright headers in ChaptersSeekBar and FocusAwareSeekBar to SPDX format --- .../org/schabi/newpipe/views/ChaptersSeekBar.kt | 17 ++--------------- .../schabi/newpipe/views/FocusAwareSeekBar.kt | 17 ++--------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt index 8137c4fc84c..707860593bf 100644 --- a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt +++ b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt @@ -1,19 +1,6 @@ /* - * Copyright (C) NewPipe Contributors - * ChaptersSeekBar.kt 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 . + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.views diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt index c51ab8a02e5..090685afedb 100644 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt @@ -1,19 +1,6 @@ /* - * Copyright (C) Eltex ltd 2019 - * FocusAwareSeekBar.kt 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 . + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.views From 9d559b126e32f2ddcf284c9bcda975240406ce74 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 19 Apr 2026 14:56:07 +0200 Subject: [PATCH 10/12] Update copyright headers and change comments --- .../main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java | 4 ---- .../main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java | 3 --- app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt | 2 +- .../main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt | 2 +- 4 files changed, 2 insertions(+), 9 deletions(-) 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 71849434417..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 @@ -146,10 +146,6 @@ public void setupAfterIntent() { } } - // `protected` matches the visibility declared in VideoPlayerUi.kt. The Kotlin parent used to - // declare this as `internal`, which the Kotlin compiler mangles to a JVM-private name that - // Java cannot see, so this class could not compile. Changing to `protected` gives the method - // a stable, unmangled JVM name accessible from Java subclasses. @Override 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 907fc1fecad..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 @@ -101,9 +101,6 @@ public void setupAfterIntent() { initPopupCloseOverlay(); } - // Same reason as MainPlayerUi: `protected` is required so this Java class can override the - // method declared in the Kotlin parent (VideoPlayerUi). Using `internal` in Kotlin produces - // a mangled JVM name that Java subclasses cannot reference. @Override protected BasePlayerGestureListener buildGestureListener() { return new PopupPlayerGestureListener(this); diff --git a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt index 707860593bf..e57ee746190 100644 --- a/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt +++ b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-FileCopyrightText: 2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.views diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt index 090685afedb..3f31bd8bae0 100644 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-FileCopyrightText: 2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.views From 80d03960dd8833bce38f8664ae1b53b0f31c2db6 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 19 Apr 2026 18:53:43 +0200 Subject: [PATCH 11/12] Fix VideoPlayerUi.kt bindings and annotations for refactor branch compatibility --- .../org/schabi/newpipe/player/ui/VideoPlayerUi.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index 567760ea531..ce32426dc85 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt @@ -1,5 +1,6 @@ package org.schabi.newpipe.player.ui +import android.annotation.SuppressLint import android.content.Intent import android.content.res.Resources import android.graphics.Bitmap @@ -223,7 +224,7 @@ abstract class VideoPlayerUi protected constructor( ShareUtils.copyToClipboard(context, player.videoUrlAtCurrentTime) true } - binding.fullScreenButton.setOnClickListener( + binding.fullscreenToggleButtonSecondaryMenu.setOnClickListener( makeOnClickListener { player.setRecovery() NavigationHelper.playOnMainPlayer( @@ -303,8 +304,8 @@ abstract class VideoPlayerUi protected constructor( binding.moreOptionsButton.setOnLongClickListener(null) binding.share.setOnClickListener(null) binding.share.setOnLongClickListener(null) - binding.fullScreenButton.setOnClickListener(null) - binding.screenRotationButton.setOnClickListener(null) + binding.fullscreenToggleButtonSecondaryMenu.setOnClickListener(null) + binding.fullscreenToggleButton.setOnClickListener(null) binding.playWithKodi.setOnClickListener(null) binding.openInBrowser.setOnClickListener(null) binding.playerCloseButton.setOnClickListener(null) @@ -962,6 +963,8 @@ abstract class VideoPlayerUi protected constructor( player.toggleShuffleModeEnabled() } + // TODO: don’t reference internal exoplayer2 resources + @SuppressLint("PrivateResource") override fun onRepeatModeChanged(@RepeatMode repeatMode: Int) { super.onRepeatModeChanged(repeatMode) @@ -1477,7 +1480,7 @@ abstract class VideoPlayerUi protected constructor( if (player.currentState == STATE_PLAYING && !isSomePopupMenuVisible) { if (v == binding.playPauseButton || // Hide controls in fullscreen immediately - (v == binding.screenRotationButton && isFullscreen) + (v == binding.fullscreenToggleButton && isFullscreen) ) { hideControls(0, 0) } else { From 05d4dd53bf1d074e4b8f736f501ec63a70d7d6c7 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 19 Apr 2026 21:25:43 +0200 Subject: [PATCH 12/12] Fix play queue next button visibility check to use size() method --- .../main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index ce32426dc85..b40bd63290a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt @@ -779,7 +779,7 @@ abstract class VideoPlayerUi protected constructor( val playQueue: PlayQueue = player.playQueue ?: return val showPrev = playQueue.index != 0 - val showNext = playQueue.index + 1 != playQueue.streams.size + 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 @@ -941,7 +941,7 @@ abstract class VideoPlayerUi protected constructor( if (!show || playQueue.index > 0) { binding.playPreviousButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) } - if (!show || playQueue.index + 1 < playQueue.streams.size) { + if (!show || playQueue.index + 1 < playQueue.size()) { binding.playNextButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) } }