From f16d3902c68cf2154f7c0ab35f1f8457cb730b4e Mon Sep 17 00:00:00 2001 From: h Date: Sat, 11 Apr 2026 20:06:33 +0200 Subject: [PATCH 1/3] 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 b68d3d94dbd..08f59a8d7b8 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 @@ -1021,6 +1059,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 a6a0884c734..9c6ae0a40d5 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 2/3] 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 5219aa5947782fa81a858af6587cff5f209a2262 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 13 Apr 2026 05:52:55 +0200 Subject: [PATCH 3/3] 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 08f59a8d7b8..43cf71d7bd6 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(); @@ -1063,17 +1070,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); } } }