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 3518aa139cf..6106d04370b 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 FlingBehavior(final Context context, final AttributeSet attrs) { 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 aeb51f63adb..5015faf7600 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 @@ -2287,7 +2287,7 @@ public void onStateChanged(@NonNull final View bottomSheet, final int newState) // Re-enable clicks setOverlayElementsClickable(true); if (player != null) { - player.onQueueClosed(); + player.onItemsListClosed(); } 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 00000000000..4191f6378c6 --- /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, secondsInMillis: Long) + } +} 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 00000000000..856350526c9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt @@ -0,0 +1,59 @@ +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 java.util.concurrent.TimeUnit + +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 = + secondsToString(item.startTimeSeconds.toLong()) + viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds * 1000L) } + 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) + } + + private fun secondsToString(seconds: Long): String { + val hours = TimeUnit.SECONDS.toHours(seconds) + val minutes = TimeUnit.SECONDS.toMinutes(seconds) + .minus(TimeUnit.HOURS.toMinutes(hours)) + val sec = seconds + .minus(TimeUnit.HOURS.toSeconds(hours)) + .minus(TimeUnit.MINUTES.toSeconds(minutes)) + + return if (hours == 0L) String.format("%02d:%02d", minutes, sec) + else String.format("%d:%02d:%02d", hours, minutes, sec) + } + + 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 49c83634664..bcc6c6f860a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java @@ -153,7 +153,7 @@ public void stop(final boolean autoplayEnabled) { // Android TV will handle back button in case controls will be visible // (one more additional unneeded click while the player is hidden) playerImpl.hideControls(0, 0); - playerImpl.onQueueClosed(); + playerImpl.onItemsListClosed(); // 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/VideoPlayerImpl.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayerImpl.java index 10887790b80..87027adbdcc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayerImpl.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayerImpl.java @@ -78,6 +78,7 @@ import org.schabi.newpipe.R; 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; @@ -100,6 +101,7 @@ import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.info_list.StreamSegmentAdapter; import java.util.List; @@ -154,7 +156,9 @@ public class VideoPlayerImpl extends VideoPlayer private ProgressBar brightnessProgressBar; private ImageView brightnessImageView; private TextView resizingIndicator; + private TextView itemsListHeaderTitle; private ImageButton queueButton; + private ImageButton segmentsButton; private ImageButton repeatButton; private ImageButton shuffleButton; private ImageButton playWithKodi; @@ -168,14 +172,16 @@ public class VideoPlayerImpl extends VideoPlayer private ImageButton playPreviousButton; private ImageButton playNextButton; - private RelativeLayout queueLayout; + private RelativeLayout itemsListLayout; private ImageButton itemsListCloseButton; private RecyclerView itemsList; + private StreamSegmentAdapter segmentAdapter; private ItemTouchHelper itemTouchHelper; private RelativeLayout playerOverlays; private boolean queueVisible; + private boolean segmentsVisible; private MainPlayer.PlayerType playerType = MainPlayer.PlayerType.VIDEO; private ImageButton moreOptionsButton; @@ -255,7 +261,7 @@ public void handleIntent(final Intent intent) { } else { getRootView().setVisibility(View.VISIBLE); initVideoPlayer(); - onQueueClosed(); + onItemsListClosed(); // Android TV: without it focus will frame the whole player playPauseButton.requestFocus(); @@ -290,7 +296,9 @@ public void initViews(final View view) { this.brightnessProgressBar = view.findViewById(R.id.brightnessProgressBar); this.brightnessImageView = view.findViewById(R.id.brightnessImageView); this.resizingIndicator = view.findViewById(R.id.resizing_indicator); + this.itemsListHeaderTitle = view.findViewById(R.id.itemsListHeaderTitle); this.queueButton = view.findViewById(R.id.queueButton); + this.segmentsButton = view.findViewById(R.id.segmentsButton); this.repeatButton = view.findViewById(R.id.repeatButton); this.shuffleButton = view.findViewById(R.id.shuffleButton); this.playWithKodi = view.findViewById(R.id.playWithKodi); @@ -309,9 +317,9 @@ public void initViews(final View view) { this.secondaryControls = view.findViewById(R.id.secondaryControls); this.shareButton = view.findViewById(R.id.share); - this.queueLayout = view.findViewById(R.id.playQueuePanel); - this.itemsListCloseButton = view.findViewById(R.id.playQueueClose); - this.itemsList = view.findViewById(R.id.playQueue); + this.itemsListLayout = view.findViewById(R.id.itemsListPanel); + this.itemsListCloseButton = view.findViewById(R.id.itemsListClose); + this.itemsList = view.findViewById(R.id.itemsList); this.playerOverlays = view.findViewById(R.id.player_overlays); @@ -320,10 +328,20 @@ public void initViews(final View view) { titleTextView.setSelected(true); channelTextView.setSelected(true); + segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener()); + // Prevent hiding of bottom sheet via swipe inside queue this.itemsList.setNestedScrollingEnabled(false); } + private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() { + return (item, secondsInMillis) -> { + segmentAdapter.selectSegment(item); + getPlayer().seekTo(secondsInMillis); + triggerProgressUpdate(); + }; + } + @Override protected void setupSubtitleView(final @NonNull SubtitleView view, final float captionScale, @@ -354,6 +372,7 @@ private void setupElementsVisibility() { getResizeView().setVisibility(View.GONE); getRootView().findViewById(R.id.metadataView).setVisibility(View.GONE); queueButton.setVisibility(View.GONE); + segmentsButton.setVisibility(View.GONE); moreOptionsButton.setVisibility(View.GONE); getTopControlsRoot().setOrientation(LinearLayout.HORIZONTAL); primaryControls.getLayoutParams().width = LinearLayout.LayoutParams.WRAP_CONTENT; @@ -369,7 +388,7 @@ private void setupElementsVisibility() { getTopControlsRoot().setClickable(false); getTopControlsRoot().setFocusable(false); getBottomControlsRoot().bringToFront(); - onQueueClosed(); + onItemsListClosed(); } else { fullscreenButton.setVisibility(View.GONE); setupScreenRotationButton(); @@ -453,6 +472,7 @@ public void initListeners() { getRootView().setOnTouchListener(listener); queueButton.setOnClickListener(this); + segmentsButton.setOnClickListener(this); repeatButton.setOnClickListener(this); shuffleButton.setOnClickListener(this); @@ -481,7 +501,7 @@ public void onChange(final boolean selfChange) { settingsContentObserver); getRootView().addOnLayoutChangeListener(this); - ViewCompat.setOnApplyWindowInsetsListener(queueLayout, (view, windowInsets) -> { + ViewCompat.setOnApplyWindowInsetsListener(itemsListLayout, (view, windowInsets) -> { final DisplayCutoutCompat cutout = windowInsets.getDisplayCutout(); if (cutout != null) { view.setPadding(cutout.getSafeInsetLeft(), cutout.getSafeInsetTop(), @@ -780,6 +800,9 @@ public void onClick(final View v) { } else if (v.getId() == queueButton.getId()) { onQueueClicked(); return; + } else if (v.getId() == segmentsButton.getId()) { + onSegmentsClicked(); + return; } else if (v.getId() == repeatButton.getId()) { onRepeatClicked(); return; @@ -847,26 +870,50 @@ private void onQueueClicked() { buildQueue(); updatePlaybackButtons(); + itemsListHeaderTitle.setVisibility(View.GONE); hideControls(0, 0); - queueLayout.requestFocus(); - animateView(queueLayout, SLIDE_AND_ALPHA, true, + itemsListLayout.requestFocus(); + animateView(itemsListLayout, SLIDE_AND_ALPHA, true, DEFAULT_CONTROLS_DURATION); itemsList.scrollToPosition(playQueue.getIndex()); } - public void onQueueClosed() { - if (!queueVisible) { + private void onSegmentsClicked() { + segmentsVisible = true; + + hideSystemUIIfNeeded(); + buildSegments(); + + itemsListHeaderTitle.setVisibility(View.VISIBLE); + hideControls(0, 0); + itemsListLayout.requestFocus(); + animateView(itemsListLayout, SLIDE_AND_ALPHA, true, + DEFAULT_CONTROLS_DURATION); + + final int adapterPosition = getNearestStreamSegmentPosition(getPlayer() + .getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + itemsList.scrollToPosition(adapterPosition); + } + + public void onItemsListClosed() { + if (!queueVisible && !segmentsVisible) { return; } - animateView(queueLayout, SLIDE_AND_ALPHA, false, + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + animateView(itemsListLayout, SLIDE_AND_ALPHA, false, DEFAULT_CONTROLS_DURATION, 0, () -> { - // Even when queueLayout is GONE it receives touch events + // Even when itemsListLayout is GONE it receives touch events // and ruins normal behavior of the app. This line fixes it - queueLayout.setTranslationY(-queueLayout.getHeight() * 5); + itemsListLayout.setTranslationY(-itemsListLayout.getHeight() * 5); }); queueVisible = false; + segmentsVisible = false; playPauseButton.requestFocus(); } @@ -1015,7 +1062,7 @@ public void onLayoutChange(final View view, final int l, final int t, final int brightnessProgressBar.setMax(maxGestureLength); setInitialGestureValues(); - queueLayout.getLayoutParams().height = height - queueLayout.getTop(); + itemsListLayout.getLayoutParams().height = height - itemsListLayout.getTop(); } } @@ -1302,7 +1349,7 @@ public void onBroadcastReceived(final Intent intent) { } // Close it because when changing orientation from portrait // (in fullscreen mode) the size of queue layout can be larger than the screen size - onQueueClosed(); + onItemsListClosed(); break; case Intent.ACTION_SCREEN_ON: shouldUpdateOnProgress = true; @@ -1482,6 +1529,14 @@ private void showOrHideButtons() { playNextButton.setAlpha(showNext ? 1.0f : 0.0f); queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); queueButton.setAlpha(showQueue ? 1.0f : 0.0f); + + boolean showSegment = false; + if (getCurrentMetadata() != null) { + showSegment = getCurrentMetadata().getMetadata().getStreamSegments().size() > 0 + && !popupPlayerSelected(); + } + segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); + segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); } private void showSystemUIPartially() { @@ -1567,7 +1622,43 @@ private void buildQueue() { playQueueAdapter.setSelectedListener(getOnSelectedListener()); - itemsListCloseButton.setOnClickListener(view -> onQueueClosed()); + shuffleButton.setVisibility(View.VISIBLE); + repeatButton.setVisibility(View.VISIBLE); + itemsListCloseButton.setOnClickListener(view -> onItemsListClosed()); + } + + private void buildSegments() { + itemsList.setAdapter(segmentAdapter); + itemsList.setClickable(true); + itemsList.setLongClickable(false); + + itemsList.clearOnScrollListeners(); + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + if (getCurrentMetadata() != null) { + segmentAdapter.setItems(getCurrentMetadata().getMetadata()); + } + + shuffleButton.setVisibility(View.GONE); + repeatButton.setVisibility(View.GONE); + itemsListCloseButton.setOnClickListener(view -> onItemsListClosed()); + } + + private int getNearestStreamSegmentPosition(final long playbackPosition) { + int nearestPosition = 0; + final List segments = getCurrentMetadata().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); } public void useVideoSource(final boolean video) { @@ -1953,7 +2044,7 @@ public void setFragmentListener(final PlayerServiceEventListener listener) { if (!isFullscreen) { getControlsRoot().setPadding(0, 0, 0, 0); } - queueLayout.setPadding(0, 0, 0, 0); + itemsListLayout.setPadding(0, 0, 0, 0); updateQueue(); updateMetadata(); updatePlayback(); @@ -1995,6 +2086,15 @@ private void updateMetadata() { if (activityListener != null && getCurrentMetadata() != null) { activityListener.onMetadataUpdate(getCurrentMetadata().getMetadata(), playQueue); } + if (getCurrentMetadata() != null && segmentsVisible + && segmentAdapter.setItems(getCurrentMetadata().getMetadata())) { + final int adapterPosition = getNearestStreamSegmentPosition(getPlayer() + .getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + itemsList.scrollToPosition(adapterPosition); + } else if (segmentsVisible) { + onItemsListClosed(); + } } private void updatePlayback() { @@ -2016,6 +2116,9 @@ private void updateProgress(final int currentProgress, final int duration, if (activityListener != null) { activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); } + if (segmentsVisible) { + segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); + } } void stopActivityBinding() { 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 26ecb187105..347118de54b 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 CustomBottomSheetBehavior(final Context context, final AttributeSet attrs 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 00000000000..429616ec98a --- /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 145d1e2f891..eed78b3c81a 100644 --- a/app/src/main/res/layout-large-land/player.xml +++ b/app/src/main/res/layout-large-land/player.xml @@ -193,6 +193,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 00000000000..7ae66bc700d --- /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 11b8bc5db36..ed07034a5bd 100644 --- a/app/src/main/res/layout/player.xml +++ b/app/src/main/res/layout/player.xml @@ -193,6 +193,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 c70b56e9762..8ac951b695e 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