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..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; @@ -66,7 +67,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 +89,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 +152,11 @@ private enum PlayButtonAction { private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = new SeekbarPreviewThumbnailHolder(); + @NonNull + private List currentChapters = Collections.emptyList(); + @Nullable + private StreamSegment lastChapterForHaptic = null; + /*////////////////////////////////////////////////////////////////////////// // Constructor, setup, destroy @@ -586,6 +595,18 @@ public void onProgressChanged(final SeekBar seekBar, final int progress, 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(); } @@ -639,6 +660,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 +680,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 +691,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 +1066,14 @@ 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(); + lastChapterForHaptic = null; + ((ChaptersSeekBar) binding.playbackSeekBar) + .setChapters(currentChapters, info.getDuration()); + binding.currentChapterTitle.setVisibility(View.GONE); } private void updateStreamRelatedViews() { 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..20d2e57a38b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/ChaptersSeekBar.java @@ -0,0 +1,125 @@ +/* + * 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/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"> + + -