diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java index 3518aa139..6106d0437 100644 --- a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java +++ b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java @@ -27,7 +27,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior { private boolean allowScroll = true; private final Rect globalRect = new Rect(); private final List skipInterceptionOfElements = Arrays.asList( - R.id.playQueuePanel, R.id.playbackSeekBar, + R.id.itemsListPanel, R.id.playbackSeekBar, R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); @Override diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index cc845ca46..b25d23694 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -2283,7 +2283,7 @@ public final class VideoDetailFragment // Re-enable clicks setOverlayElementsClickable(true); if (player != null) { - player.closeQueue(); + player.closeItemsList(); } setOverlayLook(appBarLayout, behavior, 0); break; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentAdapter.kt b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentAdapter.kt new file mode 100644 index 000000000..62a06e29b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentAdapter.kt @@ -0,0 +1,66 @@ +package org.schabi.newpipe.info_list + +import android.util.Log +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.GroupieViewHolder +import org.schabi.newpipe.extractor.stream.StreamInfo +import kotlin.math.max + +/** + * Custom RecyclerView.Adapter/GroupieAdapter for [StreamSegmentItem] for handling selection state. + */ +class StreamSegmentAdapter( + private val listener: StreamSegmentListener +) : GroupAdapter() { + + var currentIndex: Int = 0 + private set + + /** + * Returns `true` if the provided [StreamInfo] contains segments, `false` otherwise. + */ + fun setItems(info: StreamInfo): Boolean { + if (info.streamSegments.isNotEmpty()) { + clear() + addAll(info.streamSegments.map { StreamSegmentItem(it, listener) }) + return true + } + return false + } + + fun selectSegment(segment: StreamSegmentItem) { + unSelectCurrentSegment() + currentIndex = max(0, getAdapterPosition(segment)) + segment.isSelected = true + segment.notifyChanged(StreamSegmentItem.PAYLOAD_SELECT) + } + + fun selectSegmentAt(position: Int) { + try { + selectSegment(getGroupAtAdapterPosition(position) as StreamSegmentItem) + } catch (e: IndexOutOfBoundsException) { + // Just to make sure that getGroupAtAdapterPosition doesn't close the app + // Shouldn't happen since setItems is always called before select-methods but just in case + currentIndex = 0 + Log.e("StreamSegmentAdapter", "selectSegmentAt: ${e.message}") + } + } + + private fun unSelectCurrentSegment() { + try { + val segmentItem = getGroupAtAdapterPosition(currentIndex) as StreamSegmentItem + currentIndex = 0 + segmentItem.isSelected = false + segmentItem.notifyChanged(StreamSegmentItem.PAYLOAD_SELECT) + } catch (e: IndexOutOfBoundsException) { + // Just to make sure that getGroupAtAdapterPosition doesn't close the app + // Shouldn't happen since setItems is always called before select-methods but just in case + currentIndex = 0 + Log.e("StreamSegmentAdapter", "unSelectCurrentSegment: ${e.message}") + } + } + + interface StreamSegmentListener { + fun onItemClick(item: StreamSegmentItem, seconds: Int) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt new file mode 100644 index 000000000..798b9b287 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt @@ -0,0 +1,47 @@ +package org.schabi.newpipe.info_list + +import android.widget.ImageView +import android.widget.TextView +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.GroupieViewHolder +import com.xwray.groupie.Item +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamSegment +import org.schabi.newpipe.util.ImageDisplayConstants +import org.schabi.newpipe.util.Localization + +class StreamSegmentItem( + private val item: StreamSegment, + private val onClick: StreamSegmentAdapter.StreamSegmentListener +) : Item() { + + companion object { + const val PAYLOAD_SELECT = 1 + } + + var isSelected = false + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + item.previewUrl?.let { + ImageLoader.getInstance().displayImage( + it, viewHolder.root.findViewById(R.id.previewImage), + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS + ) + } + viewHolder.root.findViewById(R.id.textViewTitle).text = item.title + viewHolder.root.findViewById(R.id.textViewStartSeconds).text = + Localization.getDurationString(item.startTimeSeconds.toLong()) + viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) } + viewHolder.root.isSelected = isSelected + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(PAYLOAD_SELECT)) { + viewHolder.root.isSelected = isSelected + return + } + super.bind(viewHolder, position, payloads) + } + + override fun getLayout() = R.layout.item_stream_segment +} diff --git a/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java index c4099e67e..e9ae1a1db 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java @@ -151,7 +151,7 @@ public final class MainPlayer extends Service { // Android TV will handle back button in case controls will be visible // (one more additional unneeded click while the player is hidden) player.hideControls(0, 0); - player.closeQueue(); + player.closeItemsList(); // Notification shows information about old stream but if a user selects // a stream from backStack it's not actual anymore // So we should hide the notification at all. diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 490a0a693..4e2edaa10 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -87,9 +87,11 @@ import org.schabi.newpipe.databinding.PlayerBinding; import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding; import org.schabi.newpipe.extractor.MediaFormat; 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.OnScrollBelowItemsListener; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; +import org.schabi.newpipe.info_list.StreamSegmentAdapter; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.event.PlayerEventListener; @@ -245,6 +247,7 @@ public final class Player implements private PlayQueue playQueue; private PlayQueueAdapter playQueueAdapter; + private StreamSegmentAdapter segmentAdapter; @Nullable private MediaSourceManager playQueueManager; @@ -301,6 +304,7 @@ public final class Player implements // fullscreen player private boolean isQueueVisible = false; + private boolean areSegmentsVisible = false; private ItemTouchHelper itemTouchHelper; /*////////////////////////////////////////////////////////////////////////// @@ -454,7 +458,7 @@ public final class Player implements binding.channelTextView.setSelected(true); // Prevent hiding of bottom sheet via swipe inside queue - binding.playQueue.setNestedScrollingEnabled(false); + binding.itemsList.setNestedScrollingEnabled(false); } private void initPlayer(final boolean playOnReady) { @@ -505,6 +509,7 @@ public final class Player implements binding.getRoot().setOnTouchListener(listener); binding.queueButton.setOnClickListener(this); + binding.segmentsButton.setOnClickListener(this); binding.repeatButton.setOnClickListener(this); binding.shuffleButton.setOnClickListener(this); @@ -533,7 +538,7 @@ public final class Player implements settingsContentObserver); binding.getRoot().addOnLayoutChangeListener(this::onLayoutChange); - ViewCompat.setOnApplyWindowInsetsListener(binding.playQueuePanel, (view, windowInsets) -> { + ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { final DisplayCutoutCompat cutout = windowInsets.getDisplayCutout(); if (cutout != null) { view.setPadding(cutout.getSafeInsetLeft(), cutout.getSafeInsetTop(), @@ -665,11 +670,11 @@ public final class Player implements playbackSkipSilence, playWhenReady, isMuted); }, () -> { - // Completed but not found in history - initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, - playbackSkipSilence, playWhenReady, isMuted); - } - )); + // Completed but not found in history + initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, + playbackSkipSilence, playWhenReady, isMuted); + } + )); } else { // Good to go... // In a case of equal PlayQueues we can re-init old one but only when it is disposed @@ -697,7 +702,7 @@ public final class Player implements } else { binding.getRoot().setVisibility(View.VISIBLE); initVideoPlayer(); - closeQueue(); + closeItemsList(); // Android TV: without it focus will frame the whole player binding.playPauseButton.requestFocus(); @@ -730,6 +735,7 @@ public final class Player implements playQueueAdapter.dispose(); } playQueueAdapter = new PlayQueueAdapter(context, playQueue); + segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener()); simpleExoPlayer.setVolume(isMuted ? 0 : 1); notifyQueueUpdateToListeners(); @@ -923,6 +929,7 @@ public final class Player implements binding.resizeTextView.setVisibility(View.GONE); binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE); binding.queueButton.setVisibility(View.GONE); + binding.segmentsButton.setVisibility(View.GONE); binding.moreOptionsButton.setVisibility(View.GONE); binding.topControls.setOrientation(LinearLayout.HORIZONTAL); binding.primaryControls.getLayoutParams().width @@ -939,7 +946,7 @@ public final class Player implements binding.topControls.setClickable(false); binding.topControls.setFocusable(false); binding.bottomControls.bringToFront(); - closeQueue(); + closeItemsList(); } else if (videoPlayerSelected()) { binding.fullScreenButton.setVisibility(View.GONE); setupScreenRotationButton(); @@ -1123,7 +1130,7 @@ public final class Player implements } // Close it because when changing orientation from portrait // (in fullscreen mode) the size of queue layout can be larger than the screen size - closeQueue(); + closeItemsList(); break; case Intent.ACTION_SCREEN_ON: // Interrupt playback only when screen turns on @@ -1484,6 +1491,10 @@ public final class Player implements notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent); + if (areSegmentsVisible) { + segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); + } + final boolean showThumbnail = prefs.getBoolean( context.getString(R.string.show_thumbnail_key), true); // setMetadata only updates the metadata when any of the metadata keys are null @@ -1696,10 +1707,10 @@ public final class Player implements controlsVisibilityHandler.removeCallbacksAndMessages(null); controlsVisibilityHandler.postDelayed(() -> { - showHideShadow(false, duration); - animateView(binding.playbackControlRoot, false, duration, 0, - this::hideSystemUIIfNeeded); - }, delay); + showHideShadow(false, duration); + animateView(binding.playbackControlRoot, false, duration, 0, + this::hideSystemUIIfNeeded); + }, delay); } private void showHideShadow(final boolean show, final long duration) { @@ -1715,6 +1726,11 @@ public final class Player implements final boolean showPrev = playQueue.getIndex() != 0; final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size(); final boolean showQueue = playQueue.getStreams().size() > 1 && !popupPlayerSelected(); + boolean showSegment = false; + if (currentMetadata != null) { + showSegment = !currentMetadata.getMetadata().getStreamSegments().isEmpty() + && !popupPlayerSelected(); + } binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE); binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f); @@ -1722,6 +1738,8 @@ public final class Player implements binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f); binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f); + binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); + binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); } private void showSystemUIPartially() { @@ -2725,6 +2743,17 @@ public final class Player implements NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); notifyMetadataUpdateToListeners(); + + if (areSegmentsVisible) { + if (segmentAdapter.setItems(info)) { + final int adapterPosition = getNearestStreamSegmentPosition( + simpleExoPlayer.getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + binding.itemsList.scrollToPosition(adapterPosition); + } else { + closeItemsList(); + } + } } private void maybeUpdateCurrentMetadata() { @@ -2787,7 +2816,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// - // Play queue and streams + // Play queue, segments and streams //////////////////////////////////////////////////////////////////////////*/ //region @@ -2835,41 +2864,90 @@ public final class Player implements hideSystemUIIfNeeded(); buildQueue(); - //updatePlaybackButtons();//TODO verify this can be removed + + binding.itemsListHeaderTitle.setVisibility(View.GONE); + binding.shuffleButton.setVisibility(View.VISIBLE); + binding.repeatButton.setVisibility(View.VISIBLE); hideControls(0, 0); - binding.playQueuePanel.requestFocus(); - animateView(binding.playQueuePanel, SLIDE_AND_ALPHA, true, + binding.itemsListPanel.requestFocus(); + animateView(binding.itemsListPanel, SLIDE_AND_ALPHA, true, DEFAULT_CONTROLS_DURATION); - binding.playQueue.scrollToPosition(playQueue.getIndex()); + binding.itemsList.scrollToPosition(playQueue.getIndex()); } private void buildQueue() { - binding.playQueue.setAdapter(playQueueAdapter); - binding.playQueue.setClickable(true); - binding.playQueue.setLongClickable(true); + binding.itemsList.setAdapter(playQueueAdapter); + binding.itemsList.setClickable(true); + binding.itemsList.setLongClickable(true); - binding.playQueue.clearOnScrollListeners(); - binding.playQueue.addOnScrollListener(getQueueScrollListener()); + binding.itemsList.clearOnScrollListeners(); + binding.itemsList.addOnScrollListener(getQueueScrollListener()); itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(binding.playQueue); + itemTouchHelper.attachToRecyclerView(binding.itemsList); playQueueAdapter.setSelectedListener(getOnSelectedListener()); - binding.playQueueClose.setOnClickListener(view -> closeQueue()); + binding.itemsListClose.setOnClickListener(view -> closeItemsList()); } - public void closeQueue() { - if (isQueueVisible) { + private void onSegmentsClicked() { + areSegmentsVisible = true; + + hideSystemUIIfNeeded(); + buildSegments(); + + binding.itemsListHeaderTitle.setVisibility(View.VISIBLE); + binding.shuffleButton.setVisibility(View.GONE); + binding.repeatButton.setVisibility(View.GONE); + + hideControls(0, 0); + binding.itemsListPanel.requestFocus(); + animateView(binding.itemsListPanel, SLIDE_AND_ALPHA, true, + DEFAULT_CONTROLS_DURATION); + + final int adapterPosition = getNearestStreamSegmentPosition(simpleExoPlayer + .getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + binding.itemsList.scrollToPosition(adapterPosition); + } + + private void buildSegments() { + binding.itemsList.setAdapter(segmentAdapter); + binding.itemsList.setClickable(true); + binding.itemsList.setLongClickable(false); + + binding.itemsList.clearOnScrollListeners(); + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + if (currentMetadata != null) { + segmentAdapter.setItems(currentMetadata.getMetadata()); + } + + binding.shuffleButton.setVisibility(View.GONE); + binding.repeatButton.setVisibility(View.GONE); + binding.itemsListClose.setOnClickListener(view -> closeItemsList()); + } + + public void closeItemsList() { + if (isQueueVisible || areSegmentsVisible) { isQueueVisible = false; - animateView(binding.playQueuePanel, SLIDE_AND_ALPHA, false, + areSegmentsVisible = false; + + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + animateView(binding.itemsListPanel, SLIDE_AND_ALPHA, false, DEFAULT_CONTROLS_DURATION, 0, () -> { // Even when queueLayout is GONE it receives touch events // and ruins normal behavior of the app. This line fixes it - binding.playQueuePanel.setTranslationY( - -binding.playQueuePanel.getHeight() * 5); + binding.itemsListPanel.setTranslationY( + -binding.itemsListPanel.getHeight() * 5); }); binding.playPauseButton.requestFocus(); } @@ -2882,12 +2960,33 @@ public final class Player implements if (playQueue != null && !playQueue.isComplete()) { playQueue.fetch(); } else if (binding != null) { - binding.playQueue.clearOnScrollListeners(); + binding.itemsList.clearOnScrollListeners(); } } }; } + private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() { + return (item, seconds) -> { + segmentAdapter.selectSegment(item); + seekTo(seconds * 1000); + triggerProgressUpdate(); + }; + } + + private int getNearestStreamSegmentPosition(final long playbackPosition) { + int nearestPosition = 0; + final List segments = currentMetadata.getMetadata().getStreamSegments(); + + for (int i = 0; i < segments.size(); i++) { + if (segments.get(i).getStartTimeSeconds() * 1000 > playbackPosition) { + break; + } + nearestPosition++; + } + return Math.max(0, nearestPosition - 1); + } + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { return new PlayQueueItemTouchCallback() { @Override @@ -3313,6 +3412,9 @@ public final class Player implements } else if (v.getId() == binding.queueButton.getId()) { onQueueClicked(); return; + } else if (v.getId() == binding.segmentsButton.getId()) { + onSegmentsClicked(); + return; } else if (v.getId() == binding.repeatButton.getId()) { onRepeatClicked(); return; @@ -3610,8 +3712,8 @@ public final class Player implements binding.brightnessProgressBar.setMax(maxGestureLength); setInitialGestureValues(); - binding.playQueuePanel.getLayoutParams().height - = height - binding.playQueuePanel.getTop(); + binding.itemsListPanel.getLayoutParams().height + = height - binding.itemsListPanel.getTop(); } } @@ -3663,7 +3765,7 @@ public final class Player implements if (!isFullscreen) { binding.playbackControlRoot.setPadding(0, 0, 0, 0); } - binding.playQueuePanel.setPadding(0, 0, 0, 0); + binding.itemsListPanel.setPadding(0, 0, 0, 0); notifyQueueUpdateToListeners(); notifyMetadataUpdateToListeners(); notifyPlaybackUpdateToListeners(); diff --git a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java b/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java index 26ecb1871..347118de5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java @@ -24,7 +24,7 @@ public class CustomBottomSheetBehavior extends BottomSheetBehavior private boolean skippingInterception = false; private final List skipInterceptionOfElements = Arrays.asList( R.id.detail_content_root_layout, R.id.relatedStreamsLayout, - R.id.playQueuePanel, R.id.viewpager, R.id.bottomControls, + R.id.itemsListPanel, R.id.viewpager, R.id.bottomControls, R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); @Override diff --git a/app/src/main/res/drawable/ic_format_list_numbered_white_24.xml b/app/src/main/res/drawable/ic_format_list_numbered_white_24.xml new file mode 100644 index 000000000..429616ec9 --- /dev/null +++ b/app/src/main/res/drawable/ic_format_list_numbered_white_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-large-land/player.xml b/app/src/main/res/layout-large-land/player.xml index 7213f4020..bc484cd97 100644 --- a/app/src/main/res/layout-large-land/player.xml +++ b/app/src/main/res/layout-large-land/player.xml @@ -191,6 +191,24 @@ tools:ignore="ContentDescription,RtlHardcoded" tools:visibility="visible" /> + + + + diff --git a/app/src/main/res/layout/item_stream_segment.xml b/app/src/main/res/layout/item_stream_segment.xml new file mode 100644 index 000000000..7ae66bc70 --- /dev/null +++ b/app/src/main/res/layout/item_stream_segment.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/player.xml b/app/src/main/res/layout/player.xml index bc8e292f4..a5b6ee62b 100644 --- a/app/src/main/res/layout/player.xml +++ b/app/src/main/res/layout/player.xml @@ -192,6 +192,23 @@ app:srcCompat="@drawable/ic_list_white_24dp" tools:ignore="ContentDescription,RtlHardcoded" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c70b56e97..8ac951b69 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -692,4 +692,5 @@ Show thumbnail Use thumbnail for both lock screen background and notifications Recent + Chapters \ No newline at end of file