- * ChannelInfoItemHolder .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 .
- */
-
-public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
- public final TextView itemTitleView;
- private final ImageView itemHeartView;
- private final ImageView itemPinnedView;
-
- public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
- super(infoItemBuilder, R.layout.list_comments_item, parent);
-
- itemTitleView = itemView.findViewById(R.id.itemTitleView);
- itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
- itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
- }
-
- @Override
- public void updateFromItem(final InfoItem infoItem,
- final HistoryRecordManager historyRecordManager) {
- super.updateFromItem(infoItem, historyRecordManager);
-
- if (!(infoItem instanceof CommentsInfoItem)) {
- return;
- }
- final CommentsInfoItem item = (CommentsInfoItem) infoItem;
-
- itemTitleView.setText(item.getUploaderName());
-
- itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
-
- itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java
deleted file mode 100644
index 92e37afd8d4..00000000000
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java
+++ /dev/null
@@ -1,243 +0,0 @@
-package org.schabi.newpipe.info_list.holder;
-
-import android.text.TextUtils;
-import android.text.method.LinkMovementMethod;
-import android.text.style.URLSpan;
-import android.text.util.Linkify;
-import android.util.Log;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.core.text.util.LinkifyCompat;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.extractor.InfoItem;
-import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
-import org.schabi.newpipe.info_list.InfoItemBuilder;
-import org.schabi.newpipe.local.history.HistoryRecordManager;
-import org.schabi.newpipe.util.CommentTextOnTouchListener;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.PicassoHelper;
-import org.schabi.newpipe.util.external_communication.ShareUtils;
-import org.schabi.newpipe.util.external_communication.TimestampExtractor;
-
-import java.util.Objects;
-
-public class CommentsMiniInfoItemHolder extends InfoItemHolder {
- private static final String TAG = "CommentsMiniIIHolder";
-
- private static final int COMMENT_DEFAULT_LINES = 2;
- private static final int COMMENT_EXPANDED_LINES = 1000;
-
- private final int commentHorizontalPadding;
- private final int commentVerticalPadding;
-
- private final RelativeLayout itemRoot;
- private final ImageView itemThumbnailView;
- private final TextView itemContentView;
- private final TextView itemLikesCountView;
- private final TextView itemPublishedTime;
-
- private String commentText;
- private String streamUrl;
-
- CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
- final ViewGroup parent) {
- super(infoItemBuilder, layoutId, parent);
-
- itemRoot = itemView.findViewById(R.id.itemRoot);
- itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
- itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
- itemPublishedTime = itemView.findViewById(R.id.itemPublishedTime);
- itemContentView = itemView.findViewById(R.id.itemCommentContentView);
-
- commentHorizontalPadding = (int) infoItemBuilder.getContext()
- .getResources().getDimension(R.dimen.comments_horizontal_padding);
- commentVerticalPadding = (int) infoItemBuilder.getContext()
- .getResources().getDimension(R.dimen.comments_vertical_padding);
- }
-
- public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
- final ViewGroup parent) {
- this(infoItemBuilder, R.layout.list_comments_mini_item, parent);
- }
-
- @Override
- public void updateFromItem(final InfoItem infoItem,
- final HistoryRecordManager historyRecordManager) {
- if (!(infoItem instanceof CommentsInfoItem)) {
- return;
- }
- final CommentsInfoItem item = (CommentsInfoItem) infoItem;
-
- PicassoHelper.loadAvatar(item.getUploaderAvatarUrl()).into(itemThumbnailView);
- if (PicassoHelper.getShouldLoadImages()) {
- itemThumbnailView.setVisibility(View.VISIBLE);
- itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
- commentVerticalPadding, commentVerticalPadding);
- } else {
- itemThumbnailView.setVisibility(View.GONE);
- itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
- commentHorizontalPadding, commentVerticalPadding);
- }
-
-
- itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
-
- streamUrl = item.getUrl();
-
- itemContentView.setLines(COMMENT_DEFAULT_LINES);
- commentText = item.getCommentText();
- itemContentView.setText(commentText, TextView.BufferType.SPANNABLE);
- itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
-
- if (itemContentView.getLineCount() == 0) {
- itemContentView.post(this::ellipsize);
- } else {
- ellipsize();
- }
-
- if (item.getLikeCount() >= 0) {
- itemLikesCountView.setText(
- Localization.shortCount(
- itemBuilder.getContext(),
- item.getLikeCount()));
- } else {
- itemLikesCountView.setText("-");
- }
-
- if (item.getUploadDate() != null) {
- itemPublishedTime.setText(Localization.relativeTime(item.getUploadDate()
- .offsetDateTime()));
- } else {
- itemPublishedTime.setText(item.getTextualUploadDate());
- }
-
- itemView.setOnClickListener(view -> {
- toggleEllipsize();
- if (itemBuilder.getOnCommentsSelectedListener() != null) {
- itemBuilder.getOnCommentsSelectedListener().selected(item);
- }
- });
-
-
- itemView.setOnLongClickListener(view -> {
- if (DeviceUtils.isTv(itemBuilder.getContext())) {
- openCommentAuthor(item);
- } else {
- ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText);
- }
- return true;
- });
- }
-
- private void openCommentAuthor(final CommentsInfoItem item) {
- if (TextUtils.isEmpty(item.getUploaderUrl())) {
- return;
- }
- final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext();
- try {
- NavigationHelper.openChannelFragment(
- activity.getSupportFragmentManager(),
- item.getServiceId(),
- item.getUploaderUrl(),
- item.getUploaderName());
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
- }
- }
-
- private void allowLinkFocus() {
- itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
- }
-
- private void denyLinkFocus() {
- itemContentView.setMovementMethod(null);
- }
-
- private boolean shouldFocusLinks() {
- if (itemView.isInTouchMode()) {
- return false;
- }
-
- final URLSpan[] urls = itemContentView.getUrls();
-
- return urls != null && urls.length != 0;
- }
-
- private void determineLinkFocus() {
- if (shouldFocusLinks()) {
- allowLinkFocus();
- } else {
- denyLinkFocus();
- }
- }
-
- private void ellipsize() {
- boolean hasEllipsis = false;
-
- if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
- final int endOfLastLine = itemContentView
- .getLayout()
- .getLineEnd(COMMENT_DEFAULT_LINES - 1);
- int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2);
- if (end == -1) {
- end = Math.max(endOfLastLine - 2, 0);
- }
- final String newVal = itemContentView.getText().subSequence(0, end) + " …";
- itemContentView.setText(newVal);
- hasEllipsis = true;
- }
-
- linkify();
-
- if (hasEllipsis) {
- denyLinkFocus();
- } else {
- determineLinkFocus();
- }
- }
-
- private void toggleEllipsize() {
- if (itemContentView.getText().toString().equals(commentText)) {
- if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
- ellipsize();
- }
- } else {
- expand();
- }
- }
-
- private void expand() {
- itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
- itemContentView.setText(commentText);
- linkify();
- determineLinkFocus();
- }
-
- private void linkify() {
- LinkifyCompat.addLinks(itemContentView, Linkify.WEB_URLS);
- LinkifyCompat.addLinks(itemContentView, TimestampExtractor.TIMESTAMPS_PATTERN, null, null,
- (match, url) -> {
- try {
- final var timestampMatch = TimestampExtractor
- .getTimestampFromMatcher(match, commentText);
- if (timestampMatch == null) {
- return url;
- }
- return streamUrl + url.replace(Objects.requireNonNull(match.group(0)),
- "#timestamp=" + timestampMatch.seconds());
- } catch (final Exception ex) {
- Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex);
- return url;
- }
- });
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java
new file mode 100644
index 00000000000..f1682b4e4d8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java
@@ -0,0 +1,17 @@
+package org.schabi.newpipe.info_list.holder;
+
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.info_list.InfoItemBuilder;
+
+/**
+ * Playlist card layout.
+ */
+public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder {
+
+ public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java
index bf5f57db3a7..c9216d9a9e5 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java
@@ -9,7 +9,7 @@
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.Localization;
public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
@@ -46,7 +46,7 @@ public void updateFromItem(final InfoItem infoItem,
.localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount()));
itemUploaderView.setText(item.getUploaderName());
- PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
+ PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView);
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnPlaylistSelectedListener() != null) {
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java
new file mode 100644
index 00000000000..807bad6e06e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java
@@ -0,0 +1,16 @@
+package org.schabi.newpipe.info_list.holder;
+
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.info_list.InfoItemBuilder;
+
+/**
+ * Card layout for stream.
+ */
+public class StreamCardInfoItemHolder extends StreamInfoItemHolder {
+
+ public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
+ super(infoItemBuilder, R.layout.list_stream_card_item, parent);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
index a84c9840416..80f62eed3d1 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
@@ -12,10 +12,6 @@
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization;
-import androidx.preference.PreferenceManager;
-
-import static org.schabi.newpipe.MainActivity.DEBUG;
-
/*
* Created by Christian Schabesberger on 01.08.16.
*
@@ -81,7 +77,9 @@ private String getStreamInfoDetailLine(final StreamInfoItem infoItem) {
}
}
- final String uploadDate = getFormattedRelativeUploadDate(infoItem);
+ final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(),
+ infoItem.getUploadDate(),
+ infoItem.getTextualUploadDate());
if (!TextUtils.isEmpty(uploadDate)) {
if (viewsAndDate.isEmpty()) {
return uploadDate;
@@ -92,20 +90,4 @@ private String getStreamInfoDetailLine(final StreamInfoItem infoItem) {
return viewsAndDate;
}
-
- private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) {
- if (infoItem.getUploadDate() != null) {
- String formattedRelativeTime = Localization
- .relativeTime(infoItem.getUploadDate().offsetDateTime());
-
- if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext())
- .getBoolean(itemBuilder.getContext()
- .getString(R.string.show_original_time_ago_key), false)) {
- formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")";
- }
- return formattedRelativeTime;
- } else {
- return infoItem.getTextualUploadDate();
- }
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java
index 8d17017d217..01f3be6b328 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java
@@ -14,8 +14,9 @@
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager;
+import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.views.AnimatedProgressBar;
@@ -60,8 +61,12 @@ public void updateFromItem(final InfoItem infoItem,
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
- final StreamStateEntity state2 = historyRecordManager.loadStreamState(infoItem)
- .blockingGet()[0];
+ StreamStateEntity state2 = null;
+ if (DependentPreferenceHelper
+ .getPositionsInListsEnabled(itemProgressView.getContext())) {
+ state2 = historyRecordManager.loadStreamState(infoItem)
+ .blockingGet()[0];
+ }
if (state2 != null) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getDuration());
@@ -82,7 +87,7 @@ public void updateFromItem(final InfoItem infoItem,
}
// Default thumbnail is shown on error, while loading and if the url is empty
- PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
+ PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView);
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnStreamSelectedListener() != null) {
@@ -111,9 +116,12 @@ public void updateState(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
final StreamInfoItem item = (StreamInfoItem) infoItem;
- final StreamStateEntity state = historyRecordManager
- .loadStreamState(infoItem)
- .blockingGet()[0];
+ StreamStateEntity state = null;
+ if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
+ state = historyRecordManager
+ .loadStreamState(infoItem)
+ .blockingGet()[0];
+ }
if (state != null && item.getDuration() > 0
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
itemProgressView.setMax((int) item.getDuration());
diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
new file mode 100644
index 00000000000..61721d5467c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
@@ -0,0 +1,9 @@
+package org.schabi.newpipe.ktx
+
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.core.os.BundleCompat
+
+inline fun Bundle.parcelableArrayList(key: String?): ArrayList? {
+ return BundleCompat.getParcelableArrayList(this, key, T::class.java)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ktx/SharedPreferences.kt b/app/src/main/java/org/schabi/newpipe/ktx/SharedPreferences.kt
new file mode 100644
index 00000000000..ff406fc91c3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ktx/SharedPreferences.kt
@@ -0,0 +1,7 @@
+package org.schabi.newpipe.ktx
+
+import android.content.SharedPreferences
+
+fun SharedPreferences.getStringSafe(key: String, defValue: String): String {
+ return getString(key, null) ?: defValue
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java
index 7474537fafb..53fe1677bf8 100644
--- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java
@@ -22,10 +22,11 @@
import org.schabi.newpipe.databinding.PignateFooterBinding;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.list.ListViewContract;
+import org.schabi.newpipe.info_list.ItemViewMode;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
-import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
+import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode;
/**
* This fragment is design to be used with persistent data such as
@@ -77,16 +78,23 @@ public void onResume() {
super.onResume();
if (updateFlags != 0) {
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
- final boolean useGrid = shouldUseGridLayout(requireContext());
- itemsList.setLayoutManager(
- useGrid ? getGridLayoutManager() : getListLayoutManager());
- itemListAdapter.setUseGridVariant(useGrid);
- itemListAdapter.notifyDataSetChanged();
+ refreshItemViewMode();
}
updateFlags = 0;
}
}
+ /**
+ * Updates the item view mode based on user preference.
+ */
+ private void refreshItemViewMode() {
+ final ItemViewMode itemViewMode = getItemViewMode(requireContext());
+ itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
+ ? getGridLayoutManager() : getListLayoutManager());
+ itemListAdapter.setItemViewMode(itemViewMode);
+ itemListAdapter.notifyDataSetChanged();
+ }
+
/*//////////////////////////////////////////////////////////////////////////
// Lifecycle - View
//////////////////////////////////////////////////////////////////////////*/
@@ -120,11 +128,9 @@ protected void initViews(final View rootView, final Bundle savedInstanceState) {
itemListAdapter = new LocalItemListAdapter(activity);
- final boolean useGrid = shouldUseGridLayout(requireContext());
itemsList = rootView.findViewById(R.id.items_list);
- itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
+ refreshItemViewMode();
- itemListAdapter.setUseGridVariant(useGrid);
headerRootBinding = getListHeader();
if (headerRootBinding != null) {
itemListAdapter.setHeader(headerRootBinding.getRoot());
@@ -255,7 +261,7 @@ public void handleError() {
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
- if (key.equals(getString(R.string.list_view_mode_key))) {
+ if (getString(R.string.list_view_mode_key).equals(key)) {
updateFlags |= LIST_MODE_UPDATE_FLAG;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
index 05e2fdac083..b33619dea7a 100644
--- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
@@ -12,14 +12,21 @@
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
+import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.local.history.HistoryRecordManager;
+import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalItemHolder;
+import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
+import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
+import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
+import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
+import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
import org.schabi.newpipe.util.FallbackViewHolder;
@@ -61,11 +68,19 @@ public class LocalItemListAdapter extends RecyclerView.Adapter localItems;
@@ -73,9 +88,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter, Void> {
+public final class BookmarkFragment extends BaseLocalListFragment, Void>
+ implements DebounceSavable {
+
+ private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
- protected Parcelable itemsListState;
+ Parcelable itemsListState;
private Subscription databaseSubscription;
private CompositeDisposable disposables = new CompositeDisposable();
private LocalPlaylistManager localPlaylistManager;
private RemotePlaylistManager remotePlaylistManager;
+ private ItemTouchHelper itemTouchHelper;
+
+ /* Have the bookmarked playlists been fully loaded from db */
+ private AtomicBoolean isLoadingComplete;
+
+ /* Gives enough time to avoid interrupting user sorting operations */
+ @Nullable
+ private DebounceSaver debounceSaver;
+
+ private List> deletedItems;
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Creation
@@ -65,6 +87,11 @@ public void onCreate(final Bundle savedInstanceState) {
localPlaylistManager = new LocalPlaylistManager(database);
remotePlaylistManager = new RemotePlaylistManager(database);
disposables = new CompositeDisposable();
+
+ isLoadingComplete = new AtomicBoolean();
+ debounceSaver = new DebounceSaver(3000, this);
+
+ deletedItems = new ArrayList<>();
}
@Nullable
@@ -94,12 +121,17 @@ public void onResume() {
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
+
+ itemListAdapter.setUseItemHandle(true);
}
@Override
protected void initListeners() {
super.initListeners();
+ itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
+ itemTouchHelper.attachToRecyclerView(itemsList);
+
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
@@ -107,7 +139,7 @@ public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
- NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.uid,
+ NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(),
entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
@@ -128,6 +160,14 @@ public void held(final LocalItem selectedItem) {
showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem);
}
}
+
+ @Override
+ public void drag(final LocalItem selectedItem,
+ final RecyclerView.ViewHolder viewHolder) {
+ if (itemTouchHelper != null) {
+ itemTouchHelper.startDrag(viewHolder);
+ }
+ }
});
}
@@ -139,8 +179,13 @@ public void held(final LocalItem selectedItem) {
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
- Flowable.combineLatest(localPlaylistManager.getPlaylists(),
- remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
+ if (debounceSaver != null) {
+ disposables.add(debounceSaver.getDebouncedSaver());
+ debounceSaver.setNoChangesToSave();
+ }
+ isLoadingComplete.set(false);
+
+ getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistsSubscriber());
@@ -154,6 +199,9 @@ public void startLoading(final boolean forceLoad) {
public void onPause() {
super.onPause();
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
+
+ // Save on exit
+ saveImmediate();
}
@Override
@@ -168,19 +216,27 @@ public void onDestroyView() {
}
databaseSubscription = null;
+ itemTouchHelper = null;
}
@Override
public void onDestroy() {
super.onDestroy();
+ if (debounceSaver != null) {
+ debounceSaver.getDebouncedSaveSignal().onComplete();
+ }
if (disposables != null) {
disposables.dispose();
}
+ debounceSaver = null;
disposables = null;
localPlaylistManager = null;
remotePlaylistManager = null;
itemsListState = null;
+
+ isLoadingComplete = null;
+ deletedItems = null;
}
///////////////////////////////////////////////////////////////////////////
@@ -188,10 +244,12 @@ public void onDestroy() {
///////////////////////////////////////////////////////////////////////////
private Subscriber> getPlaylistsSubscriber() {
- return new Subscriber>() {
+ return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
+ isLoadingComplete.set(false);
+
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
@@ -201,7 +259,10 @@ public void onSubscribe(final Subscription s) {
@Override
public void onNext(final List subscriptions) {
- handleResult(subscriptions);
+ if (debounceSaver == null || !debounceSaver.getIsModified()) {
+ handleResult(subscriptions);
+ isLoadingComplete.set(true);
+ }
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
@@ -214,7 +275,8 @@ public void onError(final Throwable exception) {
}
@Override
- public void onComplete() { }
+ public void onComplete() {
+ }
};
}
@@ -249,12 +311,183 @@ protected void resetFragment() {
}
}
+ /*//////////////////////////////////////////////////////////////////////////
+ // Playlist Metadata Manipulation
+ //////////////////////////////////////////////////////////////////////////*/
+
+ private void changeLocalPlaylistName(final long id, final String name) {
+ if (localPlaylistManager == null) {
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "Updating playlist id=[" + id + "] "
+ + "with new name=[" + name + "] items");
+ }
+
+ final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
+ new ErrorInfo(throwable,
+ UserAction.REQUESTED_BOOKMARK,
+ "Changing playlist name")));
+ disposables.add(disposable);
+ }
+
+ private void deleteItem(final PlaylistLocalItem item) {
+ if (itemListAdapter == null) {
+ return;
+ }
+ itemListAdapter.removeItem(item);
+
+ if (item instanceof PlaylistMetadataEntry) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM));
+ } else if (item instanceof PlaylistRemoteEntity) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM));
+ }
+
+ if (debounceSaver != null) {
+ debounceSaver.setHasChangesToSave();
+ saveImmediate();
+ }
+ }
+
+ @Override
+ public void saveImmediate() {
+ if (itemListAdapter == null) {
+ return;
+ }
+
+ // List must be loaded and modified in order to save
+ if (isLoadingComplete == null || debounceSaver == null
+ || !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
+ return;
+ }
+
+ final List items = itemListAdapter.getItemsList();
+ final List localItemsUpdate = new ArrayList<>();
+ final List localItemsDeleteUid = new ArrayList<>();
+ final List remoteItemsUpdate = new ArrayList<>();
+ final List remoteItemsDeleteUid = new ArrayList<>();
+
+ // Calculate display index
+ for (int i = 0; i < items.size(); i++) {
+ final LocalItem item = items.get(i);
+
+ if (item instanceof PlaylistMetadataEntry
+ && ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
+ ((PlaylistMetadataEntry) item).setDisplayIndex(i);
+ localItemsUpdate.add((PlaylistMetadataEntry) item);
+ } else if (item instanceof PlaylistRemoteEntity
+ && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
+ ((PlaylistRemoteEntity) item).setDisplayIndex(i);
+ remoteItemsUpdate.add((PlaylistRemoteEntity) item);
+ }
+ }
+
+ // Find deleted items
+ for (final Pair item : deletedItems) {
+ if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) {
+ localItemsDeleteUid.add(item.first);
+ } else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) {
+ remoteItemsDeleteUid.add(item.first);
+ }
+ }
+
+ deletedItems.clear();
+
+ // 1. Update local playlists
+ // 2. Update remote playlists
+ // 3. Set NoChangesToSave
+ disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid)
+ .mergeWith(remotePlaylistManager.updatePlaylists(
+ remoteItemsUpdate, remoteItemsDeleteUid))
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(() -> {
+ if (debounceSaver != null) {
+ debounceSaver.setNoChangesToSave();
+ }
+ },
+ throwable -> showError(new ErrorInfo(throwable,
+ UserAction.REQUESTED_BOOKMARK, "Saving playlist"))
+ ));
+
+ }
+
+ private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
+ // if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
+ // with an `if (shouldUseGridLayout()) ...`
+ return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
+ ItemTouchHelper.ACTION_STATE_IDLE) {
+ @Override
+ public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
+ final int viewSize,
+ final int viewSizeOutOfBounds,
+ final int totalSize,
+ final long msSinceStartScroll) {
+ final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView,
+ viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
+ final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY,
+ Math.abs(standardSpeed));
+ return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds);
+ }
+
+ @Override
+ public boolean onMove(@NonNull final RecyclerView recyclerView,
+ @NonNull final RecyclerView.ViewHolder source,
+ @NonNull final RecyclerView.ViewHolder target) {
+
+ // Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder.
+ if (itemListAdapter == null
+ || source.getItemViewType() != target.getItemViewType()
+ && !(
+ (
+ (source instanceof LocalBookmarkPlaylistItemHolder)
+ || (source instanceof RemoteBookmarkPlaylistItemHolder)
+ )
+ && (
+ (target instanceof LocalBookmarkPlaylistItemHolder)
+ || (target instanceof RemoteBookmarkPlaylistItemHolder)
+ ))
+ ) {
+ return false;
+ }
+
+ final int sourceIndex = source.getBindingAdapterPosition();
+ final int targetIndex = target.getBindingAdapterPosition();
+ final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
+ if (isSwapped && debounceSaver != null) {
+ debounceSaver.setHasChangesToSave();
+ }
+ return isSwapped;
+ }
+
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean isItemViewSwipeEnabled() {
+ return false;
+ }
+
+ @Override
+ public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
+ final int swipeDir) {
+ // Do nothing.
+ }
+ };
+ }
+
///////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
- showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid()));
+ showDeleteDialog(item.getName(), item);
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
@@ -262,9 +495,7 @@ private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
final String delete = getString(R.string.delete);
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
final boolean isThumbnailPermanent = localPlaylistManager
- .getIsPlaylistThumbnailPermanent(selectedItem.uid);
-
- final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+ .getIsPlaylistThumbnailPermanent(selectedItem.getUid());
final ArrayList items = new ArrayList<>();
items.add(rename);
@@ -277,19 +508,20 @@ private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) {
- showDeleteDialog(selectedItem.name,
- localPlaylistManager.deletePlaylist(selectedItem.uid));
+ showDeleteDialog(selectedItem.name, selectedItem);
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
- final String thumbnailUrl = localPlaylistManager
- .getAutomaticPlaylistThumbnail(selectedItem.uid);
+ final long thumbnailStreamId = localPlaylistManager
+ .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid());
localPlaylistManager
- .changePlaylistThumbnail(selectedItem.uid, thumbnailUrl, false)
+ .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
};
- builder.setItems(items.toArray(new String[0]), action).create().show();
+ new AlertDialog.Builder(activity)
+ .setItems(items.toArray(new String[0]), action)
+ .show();
}
private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
@@ -299,18 +531,17 @@ private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
dialogBinding.dialogEditText.setText(selectedItem.name);
- final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
- builder.setView(dialogBinding.getRoot())
+ new AlertDialog.Builder(activity)
+ .setView(dialogBinding.getRoot())
.setPositiveButton(R.string.rename_playlist, (dialog, which) ->
changeLocalPlaylistName(
- selectedItem.uid,
+ selectedItem.getUid(),
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
- .create()
.show();
}
- private void showDeleteDialog(final String name, final Single deleteReactor) {
+ private void showDeleteDialog(final String name, final PlaylistLocalItem item) {
if (activity == null || disposables == null) {
return;
}
@@ -319,35 +550,8 @@ private void showDeleteDialog(final String name, final Single deleteRea
.setTitle(name)
.setMessage(R.string.delete_playlist_prompt)
.setCancelable(true)
- .setPositiveButton(R.string.delete, (dialog, i) ->
- disposables.add(deleteReactor
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> { /*Do nothing on success*/ }, throwable ->
- showError(new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Deleting playlist")))))
+ .setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item))
.setNegativeButton(R.string.cancel, null)
.show();
}
-
- private void changeLocalPlaylistName(final long id, final String name) {
- if (localPlaylistManager == null) {
- return;
- }
-
- if (DEBUG) {
- Log.d(TAG, "Updating playlist id=[" + id + "] "
- + "with new name=[" + name + "] items");
- }
-
- localPlaylistManager.renamePlaylist(id, name);
- final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
- new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Changing playlist name")));
- disposables.add(disposable);
- }
}
-
diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
new file mode 100644
index 00000000000..25eb2f65226
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
@@ -0,0 +1,95 @@
+package org.schabi.newpipe.local.bookmark;
+
+import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
+import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
+import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import io.reactivex.rxjava3.core.Flowable;
+
+/**
+ * Takes care of remote and local playlists at once, hence "merged".
+ */
+public final class MergedPlaylistManager {
+
+ private MergedPlaylistManager() {
+ }
+
+ public static Flowable> getMergedOrderedPlaylists(
+ final LocalPlaylistManager localPlaylistManager,
+ final RemotePlaylistManager remotePlaylistManager) {
+ return Flowable.combineLatest(
+ localPlaylistManager.getPlaylists(),
+ remotePlaylistManager.getPlaylists(),
+ MergedPlaylistManager::merge
+ );
+ }
+
+ /**
+ * Merge localPlaylists and remotePlaylists by the display index.
+ * If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}.
+ *
+ * @param localPlaylists local playlists, already sorted by display index
+ * @param remotePlaylists remote playlists, already sorted by display index
+ * @return merged playlists
+ */
+ public static List merge(
+ final List localPlaylists,
+ final List remotePlaylists) {
+
+ // This algorithm is similar to the merge operation in merge sort.
+ final List result = new ArrayList<>(
+ localPlaylists.size() + remotePlaylists.size());
+ final List itemsWithSameIndex = new ArrayList<>();
+
+ int i = 0;
+ int j = 0;
+ while (i < localPlaylists.size()) {
+ while (j < remotePlaylists.size()) {
+ if (remotePlaylists.get(j).getDisplayIndex()
+ <= localPlaylists.get(i).getDisplayIndex()) {
+ addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
+ j++;
+ } else {
+ break;
+ }
+ }
+ addItem(result, localPlaylists.get(i), itemsWithSameIndex);
+ i++;
+ }
+ while (j < remotePlaylists.size()) {
+ addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
+ j++;
+ }
+ addItemsWithSameIndex(result, itemsWithSameIndex);
+
+ return result;
+ }
+
+ private static void addItem(final List result,
+ final PlaylistLocalItem item,
+ final List itemsWithSameIndex) {
+ if (!itemsWithSameIndex.isEmpty()
+ && itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) {
+ // The new item has a different display index, add previous items with same
+ // index to the result.
+ addItemsWithSameIndex(result, itemsWithSameIndex);
+ itemsWithSameIndex.clear();
+ }
+ itemsWithSameIndex.add(item);
+ }
+
+ private static void addItemsWithSameIndex(final List result,
+ final List itemsWithSameIndex) {
+ Collections.sort(itemsWithSameIndex,
+ Comparator.comparing(PlaylistLocalItem::getOrderingName,
+ Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
+ result.addAll(itemsWithSameIndex);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
index 88dec3911a5..478ef8039fe 100644
--- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
@@ -4,6 +4,7 @@
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -13,7 +14,8 @@
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
+import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.LocalItemListAdapter;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
@@ -28,6 +30,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
private RecyclerView playlistRecyclerView;
private LocalItemListAdapter playlistAdapter;
+ private TextView playlistDuplicateIndicator;
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
@@ -63,8 +66,9 @@ public void onViewCreated(@NonNull final View view, @Nullable final Bundle saved
playlistAdapter = new LocalItemListAdapter(getActivity());
playlistAdapter.setSelectedListener(selectedItem -> {
final List entities = getStreamEntities();
- if (selectedItem instanceof PlaylistMetadataEntry && entities != null) {
- onPlaylistSelected(playlistManager, (PlaylistMetadataEntry) selectedItem, entities);
+ if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) {
+ onPlaylistSelected(playlistManager,
+ (PlaylistDuplicatesEntry) selectedItem, entities);
}
});
@@ -72,10 +76,13 @@ public void onViewCreated(@NonNull final View view, @Nullable final Bundle saved
playlistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
playlistRecyclerView.setAdapter(playlistAdapter);
+ playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate);
+
final View newPlaylistButton = view.findViewById(R.id.newPlaylist);
newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog());
- playlistDisposables.add(playlistManager.getPlaylists()
+ playlistDisposables.add(playlistManager
+ .getPlaylistDuplicates(getStreamEntities().get(0).getUrl())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::onPlaylistsReceived));
}
@@ -117,31 +124,51 @@ public void openCreatePlaylistDialog() {
requireDialog().dismiss();
}
- private void onPlaylistsReceived(@NonNull final List playlists) {
- if (playlistAdapter != null && playlistRecyclerView != null) {
+ private void onPlaylistsReceived(@NonNull final List playlists) {
+ if (playlistAdapter != null
+ && playlistRecyclerView != null
+ && playlistDuplicateIndicator != null) {
playlistAdapter.clearStreamItemList();
playlistAdapter.addItems(playlists);
playlistRecyclerView.setVisibility(View.VISIBLE);
+ playlistDuplicateIndicator.setVisibility(
+ anyPlaylistContainsDuplicates(playlists) ? View.VISIBLE : View.GONE);
}
}
+ private boolean anyPlaylistContainsDuplicates(final List playlists) {
+ return playlists.stream()
+ .anyMatch(playlist -> playlist.timesStreamIsContained > 0);
+ }
+
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
- @NonNull final PlaylistMetadataEntry playlist,
+ @NonNull final PlaylistDuplicatesEntry playlist,
@NonNull final List streams) {
- final Toast successToast = Toast.makeText(getContext(),
- R.string.playlist_add_stream_success, Toast.LENGTH_SHORT);
-
- if (playlist.thumbnailUrl
- .equals("drawable://" + R.drawable.placeholder_thumbnail_playlist)) {
- playlistDisposables.add(manager
- .changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl(), false)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> successToast.show()));
+
+ final String toastText;
+ if (playlist.timesStreamIsContained > 0) {
+ toastText = getString(R.string.playlist_add_stream_success_duplicate,
+ playlist.timesStreamIsContained);
+ } else {
+ toastText = getString(R.string.playlist_add_stream_success);
}
- playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
+ final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
+
+ playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams)
.observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> successToast.show()));
+ .subscribe(ignored -> {
+ successToast.show();
+
+ if (playlist.thumbnailUrl != null
+ && playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
+ playlistDisposables.add(manager
+ .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
+ false)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(ignore -> successToast.show()));
+ }
+ }));
requireDialog().dismiss();
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
index 07edb04997d..ed65d4048e8 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
@@ -43,11 +43,13 @@ class FeedDatabaseManager(context: Context) {
fun getStreams(
groupId: Long,
includePlayedStreams: Boolean,
+ includePartiallyPlayedStreams: Boolean,
includeFutureStreams: Boolean
): Maybe> {
return feedTable.getStreams(
groupId,
includePlayedStreams,
+ includePartiallyPlayedStreams,
if (includeFutureStreams) null else OffsetDateTime.now()
)
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
index b3442d3dcde..61eb4c8d201 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
@@ -36,23 +36,19 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
-import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.edit
-import androidx.core.math.MathUtils
import androidx.core.os.bundleOf
-import androidx.core.view.MenuItemCompat
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.evernote.android.state.State
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Item
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener
-import icepick.State
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -63,12 +59,14 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.databinding.FragmentFeedBinding
import org.schabi.newpipe.error.ErrorInfo
+import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.fragments.BaseStateFragment
+import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
@@ -80,6 +78,7 @@ import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
+import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime
@@ -99,8 +98,6 @@ class FeedFragment : BaseStateFragment() {
private var oldestSubscriptionUpdate: OffsetDateTime? = null
private lateinit var groupAdapter: GroupieAdapter
- @State @JvmField var showPlayedItems: Boolean = true
- @State @JvmField var showFutureItems: Boolean = true
private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
private var updateListViewModeOnResume = false
@@ -120,7 +117,7 @@ class FeedFragment : BaseStateFragment() {
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
- if (key.equals(getString(R.string.list_view_mode_key))) {
+ if (getString(R.string.list_view_mode_key).equals(key)) {
updateListViewModeOnResume = true
}
}
@@ -139,8 +136,6 @@ class FeedFragment : BaseStateFragment() {
val factory = FeedViewModel.getFactory(requireContext(), groupId)
viewModel = ViewModelProvider(this, factory)[FeedViewModel::class.java]
- showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
- showFutureItems = viewModel.getShowFutureItemsFromPreferences()
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
groupAdapter = GroupieAdapter().apply {
@@ -215,8 +210,6 @@ class FeedFragment : BaseStateFragment() {
activity.supportActionBar?.subtitle = groupName
inflater.inflate(R.menu.menu_feed_fragment, menu)
- updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items))
- updateToggleFutureItemsButton(menu.findItem(R.id.menu_item_feed_toggle_future_items))
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@@ -238,24 +231,42 @@ class FeedFragment : BaseStateFragment() {
}
}
.setPositiveButton(resources.getString(R.string.ok), null)
- .create()
.show()
return true
} else if (item.itemId == R.id.menu_item_feed_toggle_played_items) {
- showPlayedItems = !item.isChecked
- updateTogglePlayedItemsButton(item)
- viewModel.togglePlayedItems(showPlayedItems)
- viewModel.saveShowPlayedItemsToPreferences(showPlayedItems)
- } else if (item.itemId == R.id.menu_item_feed_toggle_future_items) {
- showFutureItems = !item.isChecked
- updateToggleFutureItemsButton(item)
- viewModel.toggleFutureItems(showFutureItems)
- viewModel.saveShowFutureItemsToPreferences(showFutureItems)
+ showStreamVisibilityDialog()
}
return super.onOptionsItemSelected(item)
}
+ private fun showStreamVisibilityDialog() {
+ val dialogItems = arrayOf(
+ getString(R.string.feed_show_watched),
+ getString(R.string.feed_show_partially_watched),
+ getString(R.string.feed_show_upcoming)
+ )
+
+ val checkedDialogItems = booleanArrayOf(
+ viewModel.getShowPlayedItemsFromPreferences(),
+ viewModel.getShowPartiallyPlayedItemsFromPreferences(),
+ viewModel.getShowFutureItemsFromPreferences()
+ )
+
+ AlertDialog.Builder(context!!)
+ .setTitle(R.string.feed_hide_streams_title)
+ .setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked ->
+ checkedDialogItems[which] = isChecked
+ }
+ .setPositiveButton(R.string.ok) { _, _ ->
+ viewModel.setSaveShowPlayedItems(checkedDialogItems[0])
+ viewModel.setSaveShowPartiallyPlayedItems(checkedDialogItems[1])
+ viewModel.setSaveShowFutureItems(checkedDialogItems[2])
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
+
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
activity?.supportActionBar?.subtitle = null
@@ -282,40 +293,6 @@ class FeedFragment : BaseStateFragment() {
super.onDestroyView()
}
- private fun updateTogglePlayedItemsButton(menuItem: MenuItem) {
- menuItem.isChecked = showPlayedItems
- menuItem.icon = AppCompatResources.getDrawable(
- requireContext(),
- if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off
- )
- MenuItemCompat.setTooltipText(
- menuItem,
- getString(
- if (showPlayedItems)
- R.string.feed_toggle_hide_played_items
- else
- R.string.feed_toggle_show_played_items
- )
- )
- }
-
- private fun updateToggleFutureItemsButton(menuItem: MenuItem) {
- menuItem.isChecked = showFutureItems
- menuItem.icon = AppCompatResources.getDrawable(
- requireContext(),
- if (showFutureItems) R.drawable.ic_history_future else R.drawable.ic_history
- )
- MenuItemCompat.setTooltipText(
- menuItem,
- getString(
- if (showFutureItems)
- R.string.feed_toggle_hide_future_items
- else
- R.string.feed_toggle_show_future_items
- )
- )
- }
-
// //////////////////////////////////////////////////////////////////////////
// Handling
// //////////////////////////////////////////////////////////////////////////
@@ -416,11 +393,10 @@ class FeedFragment : BaseStateFragment() {
@SuppressLint("StringFormatMatches")
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
-
- val itemVersion = if (shouldUseGridLayout(context)) {
- StreamItem.ItemVersion.GRID
- } else {
- StreamItem.ItemVersion.NORMAL
+ val itemVersion = when (getItemViewMode(requireContext())) {
+ ItemViewMode.GRID -> StreamItem.ItemVersion.GRID
+ ItemViewMode.CARD -> StreamItem.ItemVersion.CARD
+ else -> StreamItem.ItemVersion.NORMAL
}
loadedState.items.forEach { it.itemVersion = itemVersion }
@@ -477,29 +453,38 @@ class FeedFragment : BaseStateFragment() {
if (t is FeedLoadService.RequestException &&
t.cause is ContentNotAvailableException
) {
- Single.fromCallable {
- NewPipeDatabase.getInstance(requireContext()).subscriptionDAO()
- .getSubscription(t.subscriptionId)
- }.subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(
- { subscriptionEntity ->
- handleFeedNotAvailable(
- subscriptionEntity,
- t.cause,
- errors.subList(i + 1, errors.size)
- )
- },
- { throwable -> Log.e(TAG, "Unable to process", throwable) }
- )
- return // this will be called on the remaining errors by handleFeedNotAvailable()
+ disposables.add(
+ Single.fromCallable {
+ NewPipeDatabase.getInstance(requireContext()).subscriptionDAO()
+ .getSubscription(t.subscriptionId)
+ }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ { subscriptionEntity ->
+ handleFeedNotAvailable(
+ subscriptionEntity,
+ t.cause,
+ errors.subList(i + 1, errors.size)
+ )
+ },
+ { throwable -> Log.e(TAG, "Unable to process", throwable) }
+ )
+ )
+ // this will be called on the remaining errors by handleFeedNotAvailable()
+ return@handleItemsErrors
}
}
+
+ if (errors.isNotEmpty()) {
+ // if no error was a ContentNotAvailableException, show a general error snackbar
+ ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, ""))
+ }
}
private fun handleFeedNotAvailable(
subscriptionEntity: SubscriptionEntity,
- @Nullable cause: Throwable?,
+ cause: Throwable?,
nextItemsErrors: List
) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
@@ -509,15 +494,13 @@ class FeedFragment : BaseStateFragment() {
val builder = AlertDialog.Builder(requireContext())
.setTitle(R.string.feed_load_error)
- .setPositiveButton(
- R.string.unsubscribe
- ) { _, _ ->
- SubscriptionManager(requireContext()).deleteSubscription(
- subscriptionEntity.serviceId, subscriptionEntity.url
- ).subscribe()
+ .setPositiveButton(R.string.unsubscribe) { _, _ ->
+ SubscriptionManager(requireContext())
+ .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url)
+ .subscribe()
handleItemsErrors(nextItemsErrors)
}
- .setNegativeButton(R.string.cancel) { _, _ -> }
+ .setNegativeButton(R.string.cancel, null)
var message = getString(R.string.feed_load_error_account_info, subscriptionEntity.name)
if (cause is AccountTerminatedException) {
@@ -534,7 +517,8 @@ class FeedFragment : BaseStateFragment() {
message += "\n" + cause.message
}
}
- builder.setMessage(message).create().show()
+ builder.setMessage(message)
+ .show()
}
private fun updateRelativeTimeViews() {
@@ -565,7 +549,7 @@ class FeedFragment : BaseStateFragment() {
var typeface = Typeface.DEFAULT
var backgroundSupplier = { ctx: Context ->
- resolveDrawable(ctx, R.attr.selectableItemBackground)
+ resolveDrawable(ctx, android.R.attr.selectableItemBackground)
}
if (doCheck) {
// If the uploadDate is null or true we should highlight the item
@@ -578,7 +562,7 @@ class FeedFragment : BaseStateFragment() {
LayerDrawable(
arrayOf(
resolveDrawable(ctx, R.attr.dashed_border),
- resolveDrawable(ctx, R.attr.selectableItemBackground)
+ resolveDrawable(ctx, android.R.attr.selectableItemBackground)
)
)
}
@@ -604,7 +588,7 @@ class FeedFragment : BaseStateFragment() {
// state until the user scrolls them out of the visible area which causes a update/bind-call
groupAdapter.notifyItemRangeChanged(
0,
- MathUtils.clamp(highlightCount, lastNewItemsCount, groupAdapter.itemCount)
+ highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount)
)
if (highlightCount > 0) {
@@ -623,9 +607,13 @@ class FeedFragment : BaseStateFragment() {
execOnEnd = {
// Disabled animations would result in immediately hiding the button
// after it showed up
- if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)) {
- // Hide the new items-"popup" after 10s
- hideNewItemsLoaded(true, 10000)
+ // Context can be null in some cases, so we have to make sure it is not null in
+ // order to avoid a NullPointerException
+ context?.let {
+ if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(it)) {
+ // Hide the new items button after 10s
+ hideNewItemsLoaded(true, 10000)
+ }
}
}
)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
index 27613e83e9c..665ebbe4396 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
@@ -13,9 +13,9 @@ sealed class FeedState {
data class LoadedState(
val items: List,
- val oldestUpdate: OffsetDateTime? = null,
+ val oldestUpdate: OffsetDateTime?,
val notLoadedCount: Long,
- val itemsErrors: List = emptyList()
+ val itemsErrors: List
) : FeedState()
data class ErrorState(
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
index 76d5e9d632b..728570b17e0 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
@@ -11,7 +11,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
-import io.reactivex.rxjava3.functions.Function5
+import io.reactivex.rxjava3.functions.Function6
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.App
@@ -31,18 +31,24 @@ import java.util.concurrent.TimeUnit
class FeedViewModel(
private val application: Application,
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
- initialShowPlayedItems: Boolean = true,
- initialShowFutureItems: Boolean = true
+ initialShowPlayedItems: Boolean,
+ initialShowPartiallyPlayedItems: Boolean,
+ initialShowFutureItems: Boolean
) : ViewModel() {
private val feedDatabaseManager = FeedDatabaseManager(application)
- private val toggleShowPlayedItems = BehaviorProcessor.create()
- private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems
+ private val showPlayedItems = BehaviorProcessor.create()
+ private val showPlayedItemsFlowable = showPlayedItems
.startWithItem(initialShowPlayedItems)
.distinctUntilChanged()
- private val toggleShowFutureItems = BehaviorProcessor.create()
- private val toggleShowFutureItemsFlowable = toggleShowFutureItems
+ private val showPartiallyPlayedItems = BehaviorProcessor.create()
+ private val showPartiallyPlayedItemsFlowable = showPartiallyPlayedItems
+ .startWithItem(initialShowPartiallyPlayedItems)
+ .distinctUntilChanged()
+
+ private val showFutureItems = BehaviorProcessor.create()
+ private val showFutureItemsFlowable = showFutureItems
.startWithItem(initialShowFutureItems)
.distinctUntilChanged()
@@ -52,23 +58,24 @@ class FeedViewModel(
private var combineDisposable = Flowable
.combineLatest(
FeedEventManager.events(),
- toggleShowPlayedItemsFlowable,
- toggleShowFutureItemsFlowable,
+ showPlayedItemsFlowable,
+ showPartiallyPlayedItemsFlowable,
+ showFutureItemsFlowable,
feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
- Function5 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean,
- t4: Long, t5: List ->
- return@Function5 CombineResultEventHolder(t1, t2, t3, t4, t5.firstOrNull())
+ Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean,
+ t5: Long, t6: List ->
+ return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull())
}
)
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
- .map { (event, showPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) ->
+ .map { (event, showPlayedItems, showPartiallyPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) ->
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
feedDatabaseManager
- .getStreams(groupId, showPlayedItems, showFutureItems)
+ .getStreams(groupId, showPlayedItems, showPartiallyPlayedItems, showFutureItems)
.blockingGet(arrayListOf())
else
arrayListOf()
@@ -79,7 +86,7 @@ class FeedViewModel(
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue(
when (event) {
- is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount)
+ is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf())
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors)
is ErrorResultEvent -> FeedState.ErrorState(event.error)
@@ -100,8 +107,9 @@ class FeedViewModel(
val t1: FeedEventManager.Event,
val t2: Boolean,
val t3: Boolean,
- val t4: Long,
- val t5: OffsetDateTime?
+ val t4: Boolean,
+ val t5: Long,
+ val t6: OffsetDateTime?
)
private data class CombineResultDataHolder(
@@ -111,37 +119,49 @@ class FeedViewModel(
val t4: OffsetDateTime?
)
- fun togglePlayedItems(showPlayedItems: Boolean) {
- toggleShowPlayedItems.onNext(showPlayedItems)
- }
-
- fun saveShowPlayedItemsToPreferences(showPlayedItems: Boolean) =
+ fun setSaveShowPlayedItems(showPlayedItems: Boolean) {
+ this.showPlayedItems.onNext(showPlayedItems)
PreferenceManager.getDefaultSharedPreferences(application).edit {
- this.putBoolean(application.getString(R.string.feed_show_played_items_key), showPlayedItems)
+ this.putBoolean(application.getString(R.string.feed_show_watched_items_key), showPlayedItems)
this.apply()
}
+ }
fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(application)
- fun toggleFutureItems(showFutureItems: Boolean) {
- toggleShowFutureItems.onNext(showFutureItems)
+ fun setSaveShowPartiallyPlayedItems(showPartiallyPlayedItems: Boolean) {
+ this.showPartiallyPlayedItems.onNext(showPartiallyPlayedItems)
+ PreferenceManager.getDefaultSharedPreferences(application).edit {
+ this.putBoolean(application.getString(R.string.feed_show_partially_watched_items_key), showPartiallyPlayedItems)
+ this.apply()
+ }
}
- fun saveShowFutureItemsToPreferences(showFutureItems: Boolean) =
+ fun getShowPartiallyPlayedItemsFromPreferences() = getShowPartiallyPlayedItemsFromPreferences(application)
+
+ fun setSaveShowFutureItems(showFutureItems: Boolean) {
+ this.showFutureItems.onNext(showFutureItems)
PreferenceManager.getDefaultSharedPreferences(application).edit {
this.putBoolean(application.getString(R.string.feed_show_future_items_key), showFutureItems)
this.apply()
}
+ }
fun getShowFutureItemsFromPreferences() = getShowFutureItemsFromPreferences(application)
companion object {
private fun getShowPlayedItemsFromPreferences(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context)
- .getBoolean(context.getString(R.string.feed_show_played_items_key), true)
+ .getBoolean(context.getString(R.string.feed_show_watched_items_key), true)
+
+ private fun getShowPartiallyPlayedItemsFromPreferences(context: Context) =
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.feed_show_partially_watched_items_key), true)
+
private fun getShowFutureItemsFromPreferences(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_future_items_key), true)
+
fun getFactory(context: Context, groupId: Long) = viewModelFactory {
initializer {
FeedViewModel(
@@ -149,6 +169,7 @@ class FeedViewModel(
groupId,
// Read initial value from preferences
getShowPlayedItemsFromPreferences(context.applicationContext),
+ getShowPartiallyPlayedItemsFromPreferences(context.applicationContext),
getShowFutureItemsFromPreferences(context.applicationContext)
)
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt
index 96d395aa505..4a071d6df75 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt
@@ -18,8 +18,8 @@ import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
import org.schabi.newpipe.util.Localization
-import org.schabi.newpipe.util.PicassoHelper
import org.schabi.newpipe.util.StreamTypeUtil
+import org.schabi.newpipe.util.image.PicassoHelper
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
@@ -42,12 +42,13 @@ data class StreamItem(
override fun getId(): Long = stream.uid
- enum class ItemVersion { NORMAL, MINI, GRID }
+ enum class ItemVersion { NORMAL, MINI, GRID, CARD }
override fun getLayout(): Int = when (itemVersion) {
ItemVersion.NORMAL -> R.layout.list_stream_item
ItemVersion.MINI -> R.layout.list_stream_mini_item
ItemVersion.GRID -> R.layout.list_stream_grid_item
+ ItemVersion.CARD -> R.layout.list_stream_card_item
}
override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt
index 61a4936c8c7..8ea89368d6b 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt
@@ -1,6 +1,8 @@
package org.schabi.newpipe.local.feed.notifications
+import android.app.Notification
import android.app.NotificationManager
+import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
@@ -10,48 +12,43 @@ import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
import androidx.preference.PreferenceManager
import com.squareup.picasso.Picasso
import com.squareup.picasso.Target
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
-import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
-import org.schabi.newpipe.util.PendingIntentCompat
-import org.schabi.newpipe.util.PicassoHelper
+import org.schabi.newpipe.util.image.PicassoHelper
/**
* Helper for everything related to show notifications about new streams to the user.
*/
class NotificationHelper(val context: Context) {
-
- private val manager = context.getSystemService(
- Context.NOTIFICATION_SERVICE
- ) as NotificationManager
-
+ private val manager = NotificationManagerCompat.from(context)
private val iconLoadingTargets = ArrayList()
/**
- * Show a notification about new streams from a single channel.
- * Opening the notification will open the corresponding channel page.
+ * Show notifications for new streams from a single channel. The individual notifications are
+ * expandable on Android 7.0 and later.
+ *
+ * Opening the summary notification will open the corresponding channel page. Opening the
+ * individual notifications will open the corresponding video.
*/
- fun displayNewStreamsNotification(data: FeedUpdateInfo) {
- val newStreams: List = data.newStreams
+ fun displayNewStreamsNotifications(data: FeedUpdateInfo) {
+ val newStreams = data.newStreams
val summary = context.resources.getQuantityString(
R.plurals.new_streams, newStreams.size, newStreams.size
)
- val builder = NotificationCompat.Builder(
+ val summaryBuilder = NotificationCompat.Builder(
context,
context.getString(R.string.streams_notification_channel_id)
)
- .setContentTitle(Localization.concatenateStrings(data.name, summary))
- .setContentText(
- data.listInfo.relatedItems.joinToString(
- context.getString(R.string.enumeration_comma)
- ) { x -> x.name }
- )
+ .setContentTitle(data.name)
+ .setContentText(summary)
.setNumber(newStreams.size)
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
@@ -60,36 +57,49 @@ class NotificationHelper(val context: Context) {
.setColorized(true)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
+ .setGroupSummary(true)
+ .setGroup(data.url)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
- // Build style
+ // Build a summary notification for Android versions < 7.0
val style = NotificationCompat.InboxStyle()
+ .setBigContentTitle(data.name)
newStreams.forEach { style.addLine(it.name) }
- style.setSummaryText(summary)
- style.setBigContentTitle(data.name)
- builder.setStyle(style)
+ summaryBuilder.setStyle(style)
- // open the channel page when clicking on the notification
- builder.setContentIntent(
+ // open the channel page when clicking on the summary notification
+ summaryBuilder.setContentIntent(
PendingIntentCompat.getActivity(
context,
data.pseudoId,
NavigationHelper
- .getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
+ .getChannelIntent(context, data.serviceId, data.url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
- 0
+ 0,
+ false
)
)
// a Target is like a listener for image loading events
val target = object : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
- builder.setLargeIcon(bitmap) // set only if there is actually one
- manager.notify(data.pseudoId, builder.build())
+ // set channel icon only if there is actually one (for Android versions < 7.0)
+ summaryBuilder.setLargeIcon(bitmap)
+
+ // Show individual stream notifications, set channel icon only if there is actually
+ // one
+ showStreamNotifications(newStreams, data.serviceId, bitmap)
+ // Show summary notification
+ manager.notify(data.pseudoId, summaryBuilder.build())
+
iconLoadingTargets.remove(this) // allow it to be garbage-collected
}
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
- manager.notify(data.pseudoId, builder.build())
+ // Show individual stream notifications
+ showStreamNotifications(newStreams, data.serviceId, null)
+ // Show summary notification
+ manager.notify(data.pseudoId, summaryBuilder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected
}
@@ -105,6 +115,49 @@ class NotificationHelper(val context: Context) {
PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target)
}
+ private fun showStreamNotifications(
+ newStreams: List,
+ serviceId: Int,
+ channelIcon: Bitmap?
+ ) {
+ for (stream in newStreams) {
+ val notification = createStreamNotification(stream, serviceId, channelIcon)
+ manager.notify(stream.url.hashCode(), notification)
+ }
+ }
+
+ private fun createStreamNotification(
+ item: StreamInfoItem,
+ serviceId: Int,
+ channelIcon: Bitmap?
+ ): Notification {
+ return NotificationCompat.Builder(
+ context,
+ context.getString(R.string.streams_notification_channel_id)
+ )
+ .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
+ .setLargeIcon(channelIcon)
+ .setContentTitle(item.name)
+ .setContentText(item.uploaderName)
+ .setGroup(item.uploaderUrl)
+ .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
+ .setColorized(true)
+ .setAutoCancel(true)
+ .setCategory(NotificationCompat.CATEGORY_SOCIAL)
+ .setContentIntent(
+ // Open the stream link in the player when clicking on the notification.
+ PendingIntentCompat.getActivity(
+ context,
+ item.url.hashCode(),
+ NavigationHelper.getStreamIntent(context, serviceId, item.url, item.name),
+ PendingIntent.FLAG_UPDATE_CURRENT,
+ false
+ )
+ )
+ .setSilent(true) // Avoid creating noise for individual stream notifications.
+ .build()
+ }
+
companion object {
/**
* Check whether notifications are enabled on the device.
@@ -123,9 +176,7 @@ class NotificationHelper(val context: Context) {
fun areNotificationsEnabledOnDevice(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = context.getString(R.string.streams_notification_channel_id)
- val manager = context.getSystemService(
- Context.NOTIFICATION_SERVICE
- ) as NotificationManager
+ val manager = context.getSystemService()!!
val enabled = manager.areNotificationsEnabled()
val channel = manager.getNotificationChannel(channelId)
val importance = channel?.importance
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
index 6b9580802ec..a40bf35dc52 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
@@ -55,7 +55,7 @@ class NotificationWorker(
.map { feedUpdateInfoList ->
// display notifications for each feedUpdateInfo (i.e. channel)
feedUpdateInfoList.forEach { feedUpdateInfo ->
- notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
+ notificationHelper.displayNewStreamsNotifications(feedUpdateInfo)
}
return@map Result.success()
}
@@ -137,7 +137,7 @@ class NotificationWorker(
.enqueueUniquePeriodicWork(
WORK_TAG,
if (force) {
- ExistingPeriodicWorkPolicy.REPLACE
+ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
} else {
ExistingPeriodicWorkPolicy.KEEP
},
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
index 3d19de9c693..1c2826e7a67 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
@@ -26,7 +26,7 @@ object FeedEventManager {
}
sealed class Event {
- object IdleEvent : Event()
+ data object IdleEvent : Event()
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt
index fec50a579a7..9b0f177d568 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt
@@ -1,6 +1,7 @@
package org.schabi.newpipe.local.feed.service
import android.content.Context
+import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
@@ -13,11 +14,19 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.NotificationMode
-import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.extractor.Info
+import org.schabi.newpipe.extractor.NewPipe
+import org.schabi.newpipe.extractor.ServiceList
+import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.ktx.getStringSafe
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.SubscriptionManager
-import org.schabi.newpipe.util.ExtractorHelper
+import org.schabi.newpipe.util.ChannelTabHelper
+import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo
+import org.schabi.newpipe.util.ExtractorHelper.getChannelTab
+import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean
@@ -62,12 +71,10 @@ class FeedLoadManager(private val context: Context) {
val outdatedThreshold = if (ignoreOutdatedThreshold) {
OffsetDateTime.now(ZoneOffset.UTC)
} else {
- val thresholdOutdatedSeconds = (
- defaultSharedPreferences.getString(
- context.getString(R.string.feed_update_threshold_key),
- context.getString(R.string.feed_update_threshold_default_value)
- ) ?: context.getString(R.string.feed_update_threshold_default_value)
- ).toInt()
+ val thresholdOutdatedSeconds = defaultSharedPreferences.getStringSafe(
+ context.getString(R.string.feed_update_threshold_key),
+ context.getString(R.string.feed_update_threshold_default_value)
+ ).toInt()
OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
}
@@ -75,13 +82,19 @@ class FeedLoadManager(private val context: Context) {
* subscriptions which have not been updated within the feed updated threshold
*/
val outdatedSubscriptions = when (groupId) {
- FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
+ FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(
+ outdatedThreshold
+ )
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
outdatedThreshold, NotificationMode.ENABLED
)
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
}
+ // like `currentProgress`, but counts the number of YouTube extractions that have begun, so
+ // they can be properly throttled every once in a while (see doOnNext below)
+ val youtubeExtractionCount = AtomicInteger()
+
return outdatedSubscriptions
.take(1)
.doOnNext {
@@ -97,56 +110,20 @@ class FeedLoadManager(private val context: Context) {
.observeOn(Schedulers.io())
.flatMap { Flowable.fromIterable(it) }
.takeWhile { !cancelSignal.get() }
+ .doOnNext { subscriptionEntity ->
+ // throttle YouTube extractions once every BATCH_SIZE to avoid being rate limited
+ if (subscriptionEntity.serviceId == ServiceList.YouTube.serviceId) {
+ val previousCount = youtubeExtractionCount.getAndIncrement()
+ if (previousCount != 0 && previousCount % BATCH_SIZE == 0) {
+ Thread.sleep(DELAY_BETWEEN_BATCHES_MILLIS.random())
+ }
+ }
+ }
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() }
.map { subscriptionEntity ->
- var error: Throwable? = null
- try {
- // check for and load new streams
- // either by using the dedicated feed method or by getting the channel info
- val listInfo = if (useFeedExtractor) {
- ExtractorHelper
- .getFeedInfoFallbackToChannelInfo(
- subscriptionEntity.serviceId,
- subscriptionEntity.url
- )
- .onErrorReturn {
- error = it // store error, otherwise wrapped into RuntimeException
- throw it
- }
- .blockingGet()
- } else {
- ExtractorHelper
- .getChannelInfo(
- subscriptionEntity.serviceId,
- subscriptionEntity.url,
- true
- )
- .onErrorReturn {
- error = it // store error, otherwise wrapped into RuntimeException
- throw it
- }
- .blockingGet()
- } as ListInfo
-
- return@map Notification.createOnNext(
- FeedUpdateInfo(
- subscriptionEntity,
- listInfo
- )
- )
- } catch (e: Throwable) {
- if (error == null) {
- // do this to prevent blockingGet() from wrapping into RuntimeException
- error = e
- }
-
- val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
- val wrapper =
- FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
- return@map Notification.createOnError(wrapper)
- }
+ loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences)
}
.sequential()
.observeOn(AndroidSchedulers.mainThread())
@@ -164,7 +141,112 @@ class FeedLoadManager(private val context: Context) {
}
private fun broadcastProgress() {
- FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
+ FeedEventManager.postEvent(
+ FeedEventManager.Event.ProgressEvent(
+ currentProgress.get(),
+ maxProgress.get()
+ )
+ )
+ }
+
+ private fun loadStreams(
+ subscriptionEntity: SubscriptionEntity,
+ useFeedExtractor: Boolean,
+ defaultSharedPreferences: SharedPreferences
+ ): Notification {
+ var error: Throwable? = null
+ val storeOriginalErrorAndRethrow = { e: Throwable ->
+ // keep original to prevent blockingGet() from wrapping it into RuntimeException
+ error = e
+ throw e
+ }
+
+ try {
+ // check for and load new streams
+ // either by using the dedicated feed method or by getting the channel info
+ var originalInfo: Info? = null
+ var streams: List? = null
+ val errors = ArrayList()
+
+ if (useFeedExtractor) {
+ NewPipe.getService(subscriptionEntity.serviceId)
+ .getFeedExtractor(subscriptionEntity.url)
+ ?.also { feedExtractor ->
+ // the user wants to use a feed extractor and there is one, use it
+ val feedInfo = FeedInfo.getInfo(feedExtractor)
+ errors.addAll(feedInfo.errors)
+ originalInfo = feedInfo
+ streams = feedInfo.relatedItems
+ }
+ }
+
+ if (originalInfo == null) {
+ // use the normal channel tabs extractor if either the user wants it, or
+ // the current service does not have a dedicated feed extractor
+
+ val channelInfo = getChannelInfo(
+ subscriptionEntity.serviceId,
+ subscriptionEntity.url, true
+ )
+ .onErrorReturn(storeOriginalErrorAndRethrow)
+ .blockingGet()
+ errors.addAll(channelInfo.errors)
+ originalInfo = channelInfo
+
+ streams = channelInfo.tabs
+ .filter { tab ->
+ ChannelTabHelper.fetchFeedChannelTab(
+ context,
+ defaultSharedPreferences,
+ tab
+ )
+ }
+ .map {
+ Pair(
+ getChannelTab(subscriptionEntity.serviceId, it, true)
+ .onErrorReturn(storeOriginalErrorAndRethrow)
+ .blockingGet(),
+ it
+ )
+ }
+ .flatMap { (channelTabInfo, linkHandler) ->
+ errors.addAll(channelTabInfo.errors)
+ if (channelTabInfo.relatedItems.isEmpty() &&
+ channelTabInfo.nextPage != null
+ ) {
+ val infoItemsPage = getMoreChannelTabItems(
+ subscriptionEntity.serviceId,
+ linkHandler, channelTabInfo.nextPage
+ )
+ .blockingGet()
+
+ errors.addAll(infoItemsPage.errors)
+ return@flatMap infoItemsPage.items
+ } else {
+ return@flatMap channelTabInfo.relatedItems
+ }
+ }
+ .filterIsInstance()
+ }
+
+ return Notification.createOnNext(
+ FeedUpdateInfo(
+ subscriptionEntity,
+ originalInfo!!,
+ streams!!,
+ errors,
+ )
+ )
+ } catch (e: Throwable) {
+ val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
+ val wrapper = FeedLoadService.RequestException(
+ subscriptionEntity.uid,
+ request,
+ // do this to prevent blockingGet() from wrapping into RuntimeException
+ error ?: e
+ )
+ return Notification.createOnError(wrapper)
+ }
}
/**
@@ -203,24 +285,24 @@ class FeedLoadManager(private val context: Context) {
for (notification in list) {
when {
notification.isOnNext -> {
- val subscriptionId = notification.value!!.uid
- val info = notification.value!!.listInfo
+ val info = notification.value!!
- notification.value!!.newStreams = filterNewStreams(
- notification.value!!.listInfo.relatedItems
- )
+ notification.value!!.newStreams = filterNewStreams(info.streams)
- feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
- subscriptionManager.updateFromInfo(subscriptionId, info)
+ feedDatabaseManager.upsertAll(info.uid, info.streams)
+ subscriptionManager.updateFromInfo(info)
if (info.errors.isNotEmpty()) {
feedResultsHolder.addErrors(
- FeedLoadService.RequestException.wrapList(
- subscriptionId,
- info
- )
+ info.errors.map {
+ FeedLoadService.RequestException(
+ info.uid,
+ "${info.serviceId}:${info.url}",
+ it
+ )
+ }
)
- feedDatabaseManager.markAsOutdated(subscriptionId)
+ feedDatabaseManager.markAsOutdated(info.uid)
}
}
notification.isOnError -> {
@@ -260,7 +342,19 @@ class FeedLoadManager(private val context: Context) {
/**
* How many extractions will be running in parallel.
*/
- private const val PARALLEL_EXTRACTIONS = 6
+ private const val PARALLEL_EXTRACTIONS = 3
+
+ /**
+ * How many YouTube extractions to perform before waiting [DELAY_BETWEEN_BATCHES_MILLIS]
+ * to avoid being rate limited
+ */
+ private const val BATCH_SIZE = 50
+
+ /**
+ * Wait a random delay in this range once every [BATCH_SIZE] YouTube extractions to avoid
+ * being rate limited
+ */
+ private val DELAY_BETWEEN_BATCHES_MILLIS = (6000L..12000L)
/**
* Number of items to buffer to mass-insert in the database.
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
index 0850fef8c9a..f960040de6b 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
@@ -29,6 +29,7 @@ import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
@@ -38,11 +39,8 @@ import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
-import org.schabi.newpipe.extractor.ListInfo
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
-import org.schabi.newpipe.util.PendingIntentCompat
import java.util.concurrent.TimeUnit
class FeedLoadService : Service() {
@@ -95,13 +93,7 @@ class FeedLoadService : Service() {
.doOnSubscribe {
startForeground(NOTIFICATION_ID, notificationBuilder.build())
}
- .subscribe { _, error ->
- // There seems to be a bug in the kotlin plugin as it tells you when
- // building that this can't be null:
- // "Condition 'error != null' is always 'true'"
- // However it can indeed be null
- // The suppression may be removed in further versions
- @Suppress("SENSELESS_COMPARISON")
+ .subscribe { _, error: Throwable? -> // explicitly mark error as nullable
if (error != null) {
Log.e(TAG, "Error while storing result", error)
handleError(error)
@@ -132,17 +124,7 @@ class FeedLoadService : Service() {
// Loading & Handling
// /////////////////////////////////////////////////////////////////////////
- class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
- companion object {
- fun wrapList(subscriptionId: Long, info: ListInfo): List {
- val toReturn = ArrayList(info.errors.size)
- info.errors.mapTo(toReturn) {
- RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, it)
- }
- return toReturn
- }
- }
- }
+ class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause)
// /////////////////////////////////////////////////////////////////////////
// Notification
@@ -152,8 +134,8 @@ class FeedLoadService : Service() {
private lateinit var notificationBuilder: NotificationCompat.Builder
private fun createNotification(): NotificationCompat.Builder {
- val cancelActionIntent =
- PendingIntentCompat.getBroadcast(this, NOTIFICATION_ID, Intent(ACTION_CANCEL), 0)
+ val cancelActionIntent = PendingIntentCompat
+ .getBroadcast(this, NOTIFICATION_ID, Intent(ACTION_CANCEL), 0, false)
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
.setOngoing(true)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
index 5f72a6b842a..b44eec35333 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
@@ -2,33 +2,58 @@ package org.schabi.newpipe.local.feed.service
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
-import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.extractor.Info
+import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.util.image.ImageStrategy
+/**
+ * Instances of this class might stay around in memory for some time while fetching the feed,
+ * because of [FeedLoadManager.BUFFER_COUNT_BEFORE_INSERT]. Therefore this class should contain
+ * as little data as possible to avoid out of memory errors. In particular, avoid storing whole
+ * [ChannelInfo] objects, as they might contain raw JSON info in ready channel tabs link handlers.
+ */
data class FeedUpdateInfo(
val uid: Long,
@NotificationMode
val notificationMode: Int,
val name: String,
- val avatarUrl: String,
- val listInfo: ListInfo,
+ val avatarUrl: String?,
+ val url: String,
+ val serviceId: Int,
+ // description and subscriberCount are null if the constructor info is from the fast feed method
+ val description: String?,
+ val subscriberCount: Long?,
+ val streams: List,
+ val errors: List,
) {
constructor(
subscription: SubscriptionEntity,
- listInfo: ListInfo,
+ info: Info,
+ streams: List,
+ errors: List,
) : this(
uid = subscription.uid,
notificationMode = subscription.notificationMode,
- name = subscription.name,
- avatarUrl = subscription.avatarUrl,
- listInfo = listInfo,
+ name = info.name,
+ avatarUrl = (info as? ChannelInfo)?.avatars?.let {
+ // if the newly fetched info is not from fast feed, then it contains updated avatars
+ ImageStrategy.imageListToDbUrl(it)
+ } ?: subscription.avatarUrl,
+ url = info.url,
+ serviceId = info.serviceId,
+ // there is no description and subscriberCount in the fast feed
+ description = (info as? ChannelInfo)?.description,
+ subscriberCount = (info as? ChannelInfo)?.subscriberCount,
+ streams = streams,
+ errors = errors,
)
/**
* Integer id, can be used as notification id, etc.
*/
val pseudoId: Int
- get() = listInfo.url.hashCode()
+ get() = url.hashCode()
lateinit var newStreams: List
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
index b8d2eae2d97..ed3cf548f96 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
@@ -87,7 +87,7 @@ public HistoryRecordManager(final Context context) {
* Marks a stream item as watched such that it is hidden from the feed if watched videos are
* hidden. Adds a history entry and updates the stream progress to 100%.
*
- * @see FeedViewModel#togglePlayedItems
+ * @see FeedViewModel#setSaveShowPlayedItems
* @param info the item to mark as watched
* @return a Maybe containing the ID of the item if successful
*/
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
index a20a80ae985..3302e387ec5 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
@@ -15,6 +15,7 @@
import androidx.annotation.Nullable;
import androidx.viewbinding.ViewBinding;
+import com.evernote.android.state.State;
import com.google.android.material.snackbar.Snackbar;
import org.reactivestreams.Subscriber;
@@ -28,14 +29,16 @@
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
+import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
+import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
-import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
+import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList;
import java.util.Collections;
@@ -43,13 +46,13 @@
import java.util.List;
import java.util.Objects;
-import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
public class StatisticsPlaylistFragment
- extends BaseLocalListFragment, Void> {
+ extends BaseLocalListFragment, Void>
+ implements PlaylistControlViewHolder {
private final CompositeDisposable disposables = new CompositeDisposable();
@State
Parcelable itemsListState;
@@ -195,14 +198,9 @@ public void onDestroyView() {
if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener();
}
- if (playlistControlBinding != null) {
- playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null);
- playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null);
- playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null);
- headerBinding = null;
- playlistControlBinding = null;
- }
+ headerBinding = null;
+ playlistControlBinding = null;
if (databaseSubscription != null) {
databaseSubscription.cancel();
@@ -276,12 +274,8 @@ public void handleResult(@NonNull final List result) {
itemsListState = null;
}
- playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view ->
- NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
- playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
- NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
- playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
- NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
+ PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
+
headerBinding.sortButton.setOnClickListener(view -> toggleSortMode());
hideLoading();
@@ -338,10 +332,6 @@ private void showInfoItemDialog(final StreamStatisticsEntry item) {
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteEntry(
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
- .setAction(
- StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
- (f, i) -> NavigationHelper.playOnBackgroundPlayer(
- context, getPlayQueueStartingAt(item), true))
.create()
.show();
} catch (final IllegalArgumentException e) {
@@ -374,7 +364,8 @@ private void deleteEntry(final int index) {
}
}
- private PlayQueue getPlayQueue() {
+ @Override
+ public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java
new file mode 100644
index 00000000000..16130009b6e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java
@@ -0,0 +1,54 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.local.LocalItemBuilder;
+import org.schabi.newpipe.local.history.HistoryRecordManager;
+
+import java.time.format.DateTimeFormatter;
+
+public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder {
+ private final View itemHandleView;
+
+ public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
+ }
+
+ LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
+ final ViewGroup parent) {
+ super(infoItemBuilder, layoutId, parent);
+ itemHandleView = itemView.findViewById(R.id.itemHandle);
+ }
+
+ @Override
+ public void updateFromItem(final LocalItem localItem,
+ final HistoryRecordManager historyRecordManager,
+ final DateTimeFormatter dateTimeFormatter) {
+ if (!(localItem instanceof PlaylistMetadataEntry)) {
+ return;
+ }
+ final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
+
+ itemHandleView.setOnTouchListener(getOnTouchListener(item));
+
+ super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
+ }
+
+ private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) {
+ return (view, motionEvent) -> {
+ view.performClick();
+ if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
+ && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ itemBuilder.getOnItemSelectedListener().drag(item,
+ LocalBookmarkPlaylistItemHolder.this);
+ }
+ return false;
+ };
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java
new file mode 100644
index 00000000000..33418ec987b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java
@@ -0,0 +1,17 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.local.LocalItemBuilder;
+
+/**
+ * Playlist card layout.
+ */
+public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder {
+
+ public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java
index f8c5176ec2d..336f5cfe30b 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java
@@ -4,15 +4,19 @@
import android.view.ViewGroup;
import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.Localization;
import java.time.format.DateTimeFormatter;
public class LocalPlaylistItemHolder extends PlaylistItemHolder {
+
+ private static final float GRAYED_OUT_ALPHA = 0.6f;
+
public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, parent);
}
@@ -38,6 +42,13 @@ public void updateFromItem(final LocalItem localItem,
PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView);
+ if (item instanceof PlaylistDuplicatesEntry
+ && ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) {
+ itemView.setAlpha(GRAYED_OUT_ALPHA);
+ } else {
+ itemView.setAlpha(1.0f);
+ }
+
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java
new file mode 100644
index 00000000000..7f81a527fda
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java
@@ -0,0 +1,17 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.local.LocalItemBuilder;
+
+/**
+ * Local playlist stream UI. This also includes a handle to rearrange the videos.
+ */
+public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder {
+
+ public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
index d3975832670..89a714fd7f6 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
@@ -14,8 +14,9 @@
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
+import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar;
@@ -68,7 +69,8 @@ public void updateFromItem(final LocalItem localItem,
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
- if (item.getProgressMillis() > 0) {
+ if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
+ && item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
@@ -109,7 +111,8 @@ public void updateState(final LocalItem localItem,
}
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
- if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
+ if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
+ && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java
new file mode 100644
index 00000000000..4e03d5fb105
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java
@@ -0,0 +1,13 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.local.LocalItemBuilder;
+
+public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder {
+ public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ super(infoItemBuilder, R.layout.list_stream_card_item, parent);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
index 0d88eecba70..150a35eb59c 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
@@ -14,8 +14,9 @@
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
+import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar;
@@ -97,7 +98,8 @@ public void updateFromItem(final LocalItem localItem,
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
- if (item.getProgressMillis() > 0) {
+ if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
+ && item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
@@ -141,7 +143,8 @@ public void updateState(final LocalItem localItem,
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
- if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
+ if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
+ && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java
new file mode 100644
index 00000000000..6d61d1e08bf
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java
@@ -0,0 +1,54 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
+import org.schabi.newpipe.local.LocalItemBuilder;
+import org.schabi.newpipe.local.history.HistoryRecordManager;
+
+import java.time.format.DateTimeFormatter;
+
+public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder {
+ private final View itemHandleView;
+
+ public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
+ }
+
+ RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
+ final ViewGroup parent) {
+ super(infoItemBuilder, layoutId, parent);
+ itemHandleView = itemView.findViewById(R.id.itemHandle);
+ }
+
+ @Override
+ public void updateFromItem(final LocalItem localItem,
+ final HistoryRecordManager historyRecordManager,
+ final DateTimeFormatter dateTimeFormatter) {
+ if (!(localItem instanceof PlaylistRemoteEntity)) {
+ return;
+ }
+ final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
+
+ itemHandleView.setOnTouchListener(getOnTouchListener(item));
+
+ super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
+ }
+
+ private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) {
+ return (view, motionEvent) -> {
+ view.performClick();
+ if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
+ && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ itemBuilder.getOnItemSelectedListener().drag(item,
+ RemoteBookmarkPlaylistItemHolder.this);
+ }
+ return false;
+ };
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java
new file mode 100644
index 00000000000..74a67c3db1e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java
@@ -0,0 +1,17 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.local.LocalItemBuilder;
+
+/**
+ * Playlist card UI for list item.
+ */
+public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder {
+
+ public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
index 70987a6fc1d..7657320634c 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
@@ -8,12 +8,13 @@
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import java.time.format.DateTimeFormatter;
public class RemotePlaylistItemHolder extends PlaylistItemHolder {
+
public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, parent);
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
index 68a35e72b2e..c87d9cccccd 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
@@ -5,7 +5,6 @@
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import android.content.Context;
-import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.InputType;
@@ -23,11 +22,12 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
-import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewbinding.ViewBinding;
+import com.evernote.android.state.State;
+
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.NewPipeDatabase;
@@ -35,6 +35,7 @@
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.DialogEditTextBinding;
import org.schabi.newpipe.databinding.LocalPlaylistHeaderBinding;
@@ -42,36 +43,37 @@
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
+import org.schabi.newpipe.fragments.MainFragment;
+import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
-import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
+import org.schabi.newpipe.util.PlayButtonHelper;
+import org.schabi.newpipe.util.debounce.DebounceSavable;
+import org.schabi.newpipe.util.debounce.DebounceSaver;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
-import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
-import io.reactivex.rxjava3.subjects.PublishSubject;
-public class LocalPlaylistFragment extends BaseLocalListFragment, Void> {
- // Save the list 10 seconds after the last change occurred
- private static final long SAVE_DEBOUNCE_MILLIS = 10000;
+public class LocalPlaylistFragment extends BaseLocalListFragment, Void>
+ implements PlaylistControlViewHolder, DebounceSavable {
+
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
protected Long playlistId;
@@ -88,15 +90,21 @@ public class LocalPlaylistFragment extends BaseLocalListFragment debouncedSaveSignal;
private CompositeDisposable disposables;
- /* Has the playlist been fully loaded from db */
+ /** Whether the playlist has been fully loaded from db. */
private AtomicBoolean isLoadingComplete;
- /* Has the playlist been modified (e.g. items reordered or deleted) */
- private AtomicBoolean isModified;
- /* Is the playlist currently being processed to remove watched videos */
- private boolean isRemovingWatched = false;
+ /** Used to debounce saving playlist edits to disk. */
+ private DebounceSaver debounceSaver;
+ /** Flag to prevent simultaneous rewrites of the playlist. */
+ private boolean isRewritingPlaylist = false;
+
+ /**
+ * The pager adapter that the fragment is created from when it is used as frontpage, i.e.
+ * {@link #useAsFrontPage} is {@link true}.
+ */
+ @Nullable
+ private MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter = null;
public static LocalPlaylistFragment getInstance(final long playlistId, final String name) {
final LocalPlaylistFragment instance = new LocalPlaylistFragment();
@@ -112,12 +120,11 @@ public static LocalPlaylistFragment getInstance(final long playlistId, final Str
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
- debouncedSaveSignal = PublishSubject.create();
disposables = new CompositeDisposable();
isLoadingComplete = new AtomicBoolean();
- isModified = new AtomicBoolean();
+ debounceSaver = new DebounceSaver(this);
}
@Override
@@ -223,10 +230,13 @@ public void startLoading(final boolean forceLoad) {
if (disposables != null) {
disposables.clear();
}
- disposables.add(getDebouncedSaver());
+
+ if (debounceSaver != null) {
+ disposables.add(debounceSaver.getDebouncedSaver());
+ debounceSaver.setNoChangesToSave();
+ }
isLoadingComplete.set(false);
- isModified.set(false);
playlistManager.getPlaylistStreams(playlistId)
.onBackpressureLatest()
@@ -265,14 +275,10 @@ public void onDestroyView() {
if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener();
}
- if (playlistControlBinding != null) {
- playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null);
- playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null);
- playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null);
- headerBinding = null;
- playlistControlBinding = null;
- }
+ headerBinding = null;
+ playlistControlBinding = null;
+
if (databaseSubscription != null) {
databaseSubscription.cancel();
@@ -288,19 +294,21 @@ public void onDestroyView() {
@Override
public void onDestroy() {
super.onDestroy();
- if (debouncedSaveSignal != null) {
- debouncedSaveSignal.onComplete();
+ if (debounceSaver != null) {
+ debounceSaver.getDebouncedSaveSignal().onComplete();
}
if (disposables != null) {
disposables.dispose();
}
+ if (tabsPagerAdapter != null) {
+ tabsPagerAdapter.getLocalPlaylistFragments().remove(this);
+ }
- debouncedSaveSignal = null;
+ debounceSaver = null;
playlistManager = null;
disposables = null;
isLoadingComplete = null;
- isModified = null;
}
///////////////////////////////////////////////////////////////////////////
@@ -324,7 +332,7 @@ public void onSubscribe(final Subscription s) {
@Override
public void onNext(final List streams) {
// Skip handling the result after it has been modified
- if (isModified == null || !isModified.get()) {
+ if (debounceSaver == null || !debounceSaver.getIsModified()) {
handleResult(streams);
isLoadingComplete.set(true);
}
@@ -349,24 +357,27 @@ public void onComplete() {
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.menu_item_share_playlist) {
- sharePlaylist();
+ createShareConfirmationDialog();
} else if (item.getItemId() == R.id.menu_item_rename_playlist) {
createRenameDialog();
} else if (item.getItemId() == R.id.menu_item_remove_watched) {
- if (!isRemovingWatched) {
+ if (!isRewritingPlaylist) {
new AlertDialog.Builder(requireContext())
.setMessage(R.string.remove_watched_popup_warning)
.setTitle(R.string.remove_watched_popup_title)
- .setPositiveButton(R.string.ok,
- (DialogInterface d, int id) -> removeWatchedStreams(false))
+ .setPositiveButton(R.string.ok, (d, id) ->
+ removeWatchedStreams(false))
.setNeutralButton(
R.string.remove_watched_popup_yes_and_partially_watched_videos,
- (DialogInterface d, int id) -> removeWatchedStreams(true))
+ (d, id) -> removeWatchedStreams(true))
.setNegativeButton(R.string.cancel,
- (DialogInterface d, int id) -> d.cancel())
- .create()
+ (d, id) -> d.cancel())
.show();
}
+ } else if (item.getItemId() == R.id.menu_item_remove_duplicates) {
+ if (!isRewritingPlaylist) {
+ openRemoveDuplicatesDialog();
+ }
} else {
return super.onOptionsItemSelected(item);
}
@@ -374,24 +385,41 @@ public boolean onOptionsItemSelected(final MenuItem item) {
}
/**
- * Share the playlist as a newline-separated list of stream URLs.
+ * Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is
+ * set to {@code false}. Shares the playlist name along with a list of video titles and URLs
+ * if {@code shouldSharePlaylistDetails} is set to {@code true}.
+ *
+ * @param shouldSharePlaylistDetails Whether the playlist details should be included in the
+ * shared content.
*/
- public void sharePlaylist() {
+ private void sharePlaylist(final boolean shouldSharePlaylistDetails) {
+ final Context context = requireContext();
+
disposables.add(playlistManager.getPlaylistStreams(playlistId)
.flatMapSingle(playlist -> Single.just(playlist.stream()
.map(PlaylistStreamEntry::getStreamEntity)
- .map(StreamEntity::getUrl)
+ .map(streamEntity -> {
+ if (shouldSharePlaylistDetails) {
+ return context.getString(R.string.video_details_list_item,
+ streamEntity.getTitle(), streamEntity.getUrl());
+ } else {
+ return streamEntity.getUrl();
+ }
+ })
.collect(Collectors.joining("\n"))))
.observeOn(AndroidSchedulers.mainThread())
- .subscribe(urlsText -> ShareUtils.shareText(requireContext(), name, urlsText),
+ .subscribe(urlsText -> ShareUtils.shareText(
+ context, name, shouldSharePlaylistDetails
+ ? context.getString(R.string.share_playlist_content_details,
+ name, urlsText) : urlsText),
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
}
public void removeWatchedStreams(final boolean removePartiallyWatched) {
- if (isRemovingWatched) {
+ if (isRewritingPlaylist) {
return;
}
- isRemovingWatched = true;
+ isRewritingPlaylist = true;
showLoading();
final var recordManager = new HistoryRecordManager(getContext());
@@ -417,8 +445,8 @@ public void removeWatchedStreams(final boolean removePartiallyWatched) {
if (indexInHistory < 0) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
- && playlistManager.getPlaylistThumbnail(playlistId)
- .equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
+ && playlistManager.getPlaylistThumbnailStreamId(playlistId)
+ == playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
}
@@ -438,8 +466,8 @@ public void removeWatchedStreams(final boolean removePartiallyWatched) {
&& !streamStateEntity.isFinished(duration))) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
- && playlistManager.getPlaylistThumbnail(playlistId)
- .equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
+ && playlistManager.getPlaylistThumbnailStreamId(playlistId)
+ == playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
}
@@ -456,20 +484,20 @@ public void removeWatchedStreams(final boolean removePartiallyWatched) {
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
- saveChanges();
+ debounceSaver.setHasChangesToSave();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
}
final long videoCount = itemListAdapter.getItemsList().size();
- setVideoCount(videoCount);
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
if (videoCount == 0) {
showEmptyState();
}
hideLoading();
- isRemovingWatched = false;
+ isRewritingPlaylist = false;
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
"Removing watched videos, partially watched=" + removePartiallyWatched))));
}
@@ -493,40 +521,13 @@ public void handleResult(@NonNull final List result) {
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
itemsListState = null;
}
- setVideoCount(itemListAdapter.getItemsList().size());
-
- playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> {
- NavigationHelper.playOnMainPlayer(activity, getPlayQueue());
- showHoldToAppendTipIfNeeded();
- });
- playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> {
- NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false);
- showHoldToAppendTipIfNeeded();
- });
- playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> {
- NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false);
- showHoldToAppendTipIfNeeded();
- });
- playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
- NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
- return true;
- });
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
- playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
- NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
- return true;
- });
+ PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
hideLoading();
}
- private void showHoldToAppendTipIfNeeded() {
- if (PreferenceManager.getDefaultSharedPreferences(activity)
- .getBoolean(getString(R.string.show_hold_to_append_key), true)) {
- Toast.makeText(activity, R.string.hold_to_append, Toast.LENGTH_SHORT).show();
- }
- }
-
///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling
///////////////////////////////////////////////////////////////////////////
@@ -555,15 +556,14 @@ private void createRenameDialog() {
dialogBinding.dialogEditText.setSelection(dialogBinding.dialogEditText.getText().length());
dialogBinding.dialogEditText.setText(name);
- final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getContext())
+ new AlertDialog.Builder(getContext())
.setTitle(R.string.rename_playlist)
.setView(dialogBinding.getRoot())
.setCancelable(true)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.rename, (dialogInterface, i) ->
- changePlaylistName(dialogBinding.dialogEditText.getText().toString()));
-
- dialogBuilder.show();
+ changePlaylistName(dialogBinding.dialogEditText.getText().toString()))
+ .show();
}
private void changePlaylistName(final String title) {
@@ -587,7 +587,7 @@ private void changePlaylistName(final String title) {
disposables.add(disposable);
}
- private void changeThumbnailUrl(final String thumbnailUrl, final boolean isPermanent) {
+ private void changeThumbnailStreamId(final long thumbnailStreamId, final boolean isPermanent) {
if (playlistManager == null || (!isPermanent && playlistManager
.getIsPlaylistThumbnailPermanent(playlistId))) {
return;
@@ -599,11 +599,11 @@ private void changeThumbnailUrl(final String thumbnailUrl, final boolean isPerma
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + playlistId + "] "
- + "with new thumbnail url=[" + thumbnailUrl + "]");
+ + "with new thumbnail stream id=[" + thumbnailStreamId + "]");
}
final Disposable disposable = playlistManager
- .changePlaylistThumbnail(playlistId, thumbnailUrl, isPermanent)
+ .changePlaylistThumbnail(playlistId, thumbnailStreamId, isPermanent)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show(), throwable ->
showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
@@ -616,65 +616,81 @@ private void updateThumbnailUrl() {
return;
}
- final String newThumbnailUrl;
+ final long thumbnailStreamId;
if (!itemListAdapter.getItemsList().isEmpty()) {
- newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0))
- .getStreamEntity().getThumbnailUrl();
+ thumbnailStreamId = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0))
+ .getStreamEntity().getUid();
} else {
- newThumbnailUrl = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
+ thumbnailStreamId = PlaylistEntity.DEFAULT_THUMBNAIL_ID;
}
- changeThumbnailUrl(newThumbnailUrl, false);
+ changeThumbnailStreamId(thumbnailStreamId, false);
}
- private void deleteItem(final PlaylistStreamEntry item) {
- if (itemListAdapter == null) {
+ private void openRemoveDuplicatesDialog() {
+ new AlertDialog.Builder(this.getActivity())
+ .setTitle(R.string.remove_duplicates_title)
+ .setMessage(R.string.remove_duplicates_message)
+ .setPositiveButton(R.string.ok, (dialog, i) ->
+ removeDuplicatesInPlaylist())
+ .setNeutralButton(R.string.cancel, null)
+ .show();
+ }
+
+ private void removeDuplicatesInPlaylist() {
+ if (isRewritingPlaylist) {
return;
}
+ isRewritingPlaylist = true;
+ showLoading();
- itemListAdapter.removeItem(item);
- if (playlistManager.getPlaylistThumbnail(playlistId)
- .equals(item.getStreamEntity().getThumbnailUrl())) {
- updateThumbnailUrl();
- }
+ final var streamsMaybe = playlistManager
+ .getDistinctPlaylistStreams(playlistId).firstElement();
+
+
+ disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(itemsToKeep -> {
+ itemListAdapter.clearStreamItemList();
+ itemListAdapter.addItems(itemsToKeep);
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
+ debounceSaver.setHasChangesToSave();
- setVideoCount(itemListAdapter.getItemsList().size());
- saveChanges();
+ hideLoading();
+ isRewritingPlaylist = false;
+ }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
+ "Removing duplicated streams"))));
}
- private void saveChanges() {
- if (isModified == null || debouncedSaveSignal == null) {
+ private void deleteItem(final PlaylistStreamEntry item) {
+ if (itemListAdapter == null) {
return;
}
- isModified.set(true);
- debouncedSaveSignal.onNext(System.currentTimeMillis());
- }
-
- private Disposable getDebouncedSaver() {
- if (debouncedSaveSignal == null) {
- return Disposable.empty();
+ itemListAdapter.removeItem(item);
+ if (playlistManager.getPlaylistThumbnailStreamId(playlistId) == item.getStreamId()) {
+ updateThumbnailUrl();
}
- return debouncedSaveSignal
- .debounce(SAVE_DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> saveImmediate(), throwable ->
- showError(new ErrorInfo(throwable, UserAction.SOMETHING_ELSE,
- "Debounced saver")));
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
+ debounceSaver.setHasChangesToSave();
}
- private void saveImmediate() {
+ /**
+ * Commit changes immediately if the playlist has been modified.
+ * Delete operations and other modifications will be committed to ensure that the database
+ * is up to date, e.g. when the user adds the just deleted stream from another fragment.
+ */
+ @Override
+ public void saveImmediate() {
if (playlistManager == null || itemListAdapter == null) {
return;
}
// List must be loaded and modified in order to save
- if (isLoadingComplete == null || isModified == null
- || !isLoadingComplete.get() || !isModified.get()) {
- Log.w(TAG, "Attempting to save playlist when local playlist "
- + "is not loaded or not modified: playlist id=[" + playlistId + "]");
+ if (isLoadingComplete == null || debounceSaver == null
+ || !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
return;
}
@@ -695,8 +711,8 @@ private void saveImmediate() {
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
() -> {
- if (isModified != null) {
- isModified.set(false);
+ if (debounceSaver != null) {
+ debounceSaver.setNoChangesToSave();
}
},
throwable -> showError(new ErrorInfo(throwable,
@@ -739,7 +755,7 @@ public boolean onMove(@NonNull final RecyclerView recyclerView,
final int targetIndex = target.getBindingAdapterPosition();
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped) {
- saveChanges();
+ debounceSaver.setHasChangesToSave();
}
return isSwapped;
}
@@ -793,7 +809,7 @@ context, getPlayQueueStartingAt(item), true))
.setAction(
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
(f, i) ->
- changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl(),
+ changeThumbnailStreamId(item.getStreamEntity().getUid(),
true))
.setAction(
StreamDialogDefaultEntry.DELETE,
@@ -810,14 +826,26 @@ private void setInitialData(final long pid, final String title) {
this.name = !TextUtils.isEmpty(title) ? title : "";
}
- private void setVideoCount(final long count) {
+ private void setStreamCountAndOverallDuration(final ArrayList itemsList) {
if (activity != null && headerBinding != null) {
- headerBinding.playlistStreamCount.setText(Localization
- .localizeStreamCount(activity, count));
+ final long streamCount = itemsList.size();
+ final long playlistOverallDurationSeconds = itemsList.stream()
+ .filter(PlaylistStreamEntry.class::isInstance)
+ .map(PlaylistStreamEntry.class::cast)
+ .map(PlaylistStreamEntry::getStreamEntity)
+ .mapToLong(StreamEntity::getDuration)
+ .sum();
+ headerBinding.playlistStreamCount.setText(
+ Localization.concatenateStrings(
+ Localization.localizeStreamCount(activity, streamCount),
+ Localization.getDurationString(playlistOverallDurationSeconds,
+ true, true))
+ );
}
}
- private PlayQueue getPlayQueue() {
+ @Override
+ public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}
@@ -835,5 +863,29 @@ private PlayQueue getPlayQueue(final int index) {
}
return new SinglePlayQueue(streamInfoItems, index);
}
+
+ /**
+ * Creates a dialog to confirm whether the user wants to share the playlist
+ * with the playlist details or just the list of stream URLs.
+ * After the user has made a choice, the playlist is shared.
+ */
+ private void createShareConfirmationDialog() {
+ new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.share_playlist)
+ .setMessage(R.string.share_playlist_with_titles_message)
+ .setCancelable(true)
+ .setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) ->
+ sharePlaylist(/* shouldSharePlaylistDetails= */ true)
+ )
+ .setNegativeButton(R.string.share_playlist_with_list, (dialog, which) ->
+ sharePlaylist(/* shouldSharePlaylistDetails= */ false)
+ )
+ .show();
+ }
+
+ public void setTabsPagerAdapter(
+ @Nullable final MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter) {
+ this.tabsPagerAdapter = tabsPagerAdapter;
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
index 4007d0e09bc..dd9307675de 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
@@ -2,8 +2,8 @@
import androidx.annotation.Nullable;
-import org.schabi.newpipe.R;
import org.schabi.newpipe.database.AppDatabase;
+import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO;
@@ -19,10 +19,11 @@
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Maybe;
-import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class LocalPlaylistManager {
+ private static final long THUMBNAIL_ID_LEAVE_UNCHANGED = -2;
+
private final AppDatabase database;
private final StreamDAO streamTable;
private final PlaylistDAO playlistTable;
@@ -40,30 +41,37 @@ public Maybe> createPlaylist(final String name, final List database.runInTransaction(() ->
- upsertStreams(playlistTable.insert(newPlaylist), streams, 0))
- ).subscribeOn(Schedulers.io());
+ // Save to the database directly.
+ // Make sure the new playlist is always on the top of bookmark.
+ // The index will be reassigned to non-negative number in BookmarkFragment.
+ return Maybe.fromCallable(() -> database.runInTransaction(() -> {
+ final List streamIds = streamTable.upsertAll(streams);
+ final PlaylistEntity newPlaylist = new PlaylistEntity(name, false,
+ streamIds.get(0), -1);
+
+ return insertJoinEntities(playlistTable.insert(newPlaylist),
+ streamIds, 0);
+ }
+ )).subscribeOn(Schedulers.io());
}
public Maybe> appendToPlaylist(final long playlistId,
final List streams) {
return playlistStreamTable.getMaximumIndexOf(playlistId)
.firstElement()
- .map(maxJoinIndex -> database.runInTransaction(() ->
- upsertStreams(playlistId, streams, maxJoinIndex + 1))
- ).subscribeOn(Schedulers.io());
+ .map(maxJoinIndex -> database.runInTransaction(() -> {
+ final List streamIds = streamTable.upsertAll(streams);
+ return insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1);
+ }
+ )).subscribeOn(Schedulers.io());
}
- private List upsertStreams(final long playlistId,
- final List streams,
- final int indexOffset) {
+ private List insertJoinEntities(final long playlistId, final List streamIds,
+ final int indexOffset) {
+
+ final List joinEntities = new ArrayList<>(streamIds.size());
- final List joinEntities = new ArrayList<>(streams.size());
- final List streamIds = streamTable.upsertAll(streams);
for (int index = 0; index < streamIds.size(); index++) {
joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index),
index + indexOffset));
@@ -83,6 +91,39 @@ public Completable updateJoin(final long playlistId, final List streamIds)
})).subscribeOn(Schedulers.io());
}
+ public Completable updatePlaylists(final List updateItems,
+ final List deletedItems) {
+ final List items = new ArrayList<>(updateItems.size());
+ for (final PlaylistMetadataEntry item : updateItems) {
+ items.add(new PlaylistEntity(item));
+ }
+ return Completable.fromRunnable(() -> database.runInTransaction(() -> {
+ for (final Long uid : deletedItems) {
+ playlistTable.deletePlaylist(uid);
+ }
+ for (final PlaylistEntity item : items) {
+ playlistTable.upsertPlaylist(item);
+ }
+ })).subscribeOn(Schedulers.io());
+ }
+
+ public Flowable> getDistinctPlaylistStreams(final long playlistId) {
+ return playlistStreamTable
+ .getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io());
+ }
+
+ /**
+ * Get playlists with attached information about how many times the provided stream is already
+ * contained in each playlist.
+ *
+ * @param streamUrl the stream url for which to check for duplicates
+ * @return a list of {@link PlaylistDuplicatesEntry}
+ */
+ public Flowable> getPlaylistDuplicates(final String streamUrl) {
+ return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl)
+ .subscribeOn(Schedulers.io());
+ }
+
public Flowable