From 80682c2daf43ce9a313cf779ea43a816741d28f6 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 13 Apr 2026 05:52:55 +0200 Subject: [PATCH] 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 5ef52cefd..563927e19 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 @@ -27,6 +27,7 @@ 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; @@ -152,6 +153,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa @NonNull private List currentChapters = Collections.emptyList(); + @Nullable + private StreamSegment lastChapterForHaptic = null; /*////////////////////////////////////////////////////////////////////////// @@ -595,12 +598,16 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa 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(); @@ -1078,17 +1085,9 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa // 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 a67b267d1..20d2e57a3 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 @@ package org.schabi.newpipe.views; 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.Collections; 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 final class ChaptersSeekBar extends FocusAwareSeekBar { } 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 final class ChaptersSeekBar extends FocusAwareSeekBar { 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) { + 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); - 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; + final float gapHalfWidth = (GAP_WIDTH_DP * density) / 2f; + final int paddingLeft = getPaddingLeft(); + final float trackWidth = getWidth() - paddingLeft - getPaddingRight(); - // 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; + 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; + canvas.restoreToCount(sc); - 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); + // 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); } } }