Merge branch 'dev' into pr8221

This commit is contained in:
Stypox 2024-03-29 16:09:13 +01:00
commit e1ce3fef1b
No known key found for this signature in database
GPG key ID: 4BDF1B40A49FDD23
1741 changed files with 56947 additions and 21110 deletions

View file

@ -22,10 +22,11 @@ import org.schabi.newpipe.R;
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 abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
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
//////////////////////////////////////////////////////////////////////////*/
@ -104,8 +112,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
final Resources resources = activity.getResources();
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
width += (24 * resources.getDisplayMetrics().density);
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels
/ (double) width);
final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width);
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount));
return lm;
@ -121,11 +128,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
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());
@ -256,7 +261,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
@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;
}
}

View file

@ -12,16 +12,21 @@ import androidx.recyclerview.widget.RecyclerView;
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;
@ -63,13 +68,19 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004;
private static final int LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2008;
private static final int REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2010;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
private static final int LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2003;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001;
private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002;
private static final int REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x3003;
private final LocalItemBuilder localItemBuilder;
private final ArrayList<LocalItem> localItems;
@ -77,10 +88,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private final DateTimeFormatter dateTimeFormatter;
private boolean showFooter = false;
private boolean useGridVariant = false;
private boolean useItemHandle = false;
private View header = null;
private View footer = null;
private ItemViewMode itemViewMode = ItemViewMode.LIST;
private boolean useItemHandle = false;
public LocalItemListAdapter(final Context context) {
recordManager = new HistoryRecordManager(context);
@ -170,8 +181,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
notifyDataSetChanged();
}
public void setUseGridVariant(final boolean useGridVariant) {
this.useGridVariant = useGridVariant;
public void setItemViewMode(final ItemViewMode itemViewMode) {
this.itemViewMode = itemViewMode;
}
public void setUseItemHandle(final boolean useItemHandle) {
@ -253,26 +264,43 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return FOOTER_TYPE;
}
final LocalItem item = localItems.get(position);
switch (item.getLocalItemType()) {
case PLAYLIST_LOCAL_ITEM:
if (useItemHandle) {
return LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.CARD) {
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return LOCAL_PLAYLIST_HOLDER_TYPE;
}
return useGridVariant ? LOCAL_PLAYLIST_GRID_HOLDER_TYPE
: LOCAL_PLAYLIST_HOLDER_TYPE;
case PLAYLIST_REMOTE_ITEM:
if (useItemHandle) {
return REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.CARD) {
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return REMOTE_PLAYLIST_HOLDER_TYPE;
}
return useGridVariant ? REMOTE_PLAYLIST_GRID_HOLDER_TYPE
: REMOTE_PLAYLIST_HOLDER_TYPE;
case PLAYLIST_STREAM_ITEM:
return useGridVariant
? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return STREAM_PLAYLIST_HOLDER_TYPE;
}
case STATISTIC_STREAM_ITEM:
return useGridVariant
? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
} else {
return STREAM_STATISTICS_HOLDER_TYPE;
}
default:
Log.e(TAG, "No holder type has been considered for item: ["
+ item.getLocalItemType() + "]");
@ -297,22 +325,30 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return new LocalPlaylistItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE:
return new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_HOLDER_TYPE:
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
default:
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
return new FallbackViewHolder(new View(parent.getContext()));

View file

@ -2,6 +2,7 @@ package org.schabi.newpipe.local.bookmark;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.InputType;
@ -130,7 +131,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(itemsList);
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
final FragmentManager fragmentManager = getFM();
@ -518,24 +519,53 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
final DialogEditTextBinding dialogBinding
= DialogEditTextBinding.inflate(getLayoutInflater());
final String rename = getString(R.string.rename);
final String delete = getString(R.string.delete);
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
final boolean isThumbnailPermanent = localPlaylistManager
.getIsPlaylistThumbnailPermanent(selectedItem.getUid());
final ArrayList<String> items = new ArrayList<>();
items.add(rename);
items.add(delete);
if (isThumbnailPermanent) {
items.add(unsetThumbnail);
}
final DialogInterface.OnClickListener action = (d, index) -> {
if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) {
showDeleteDialog(selectedItem.name, selectedItem);
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
final long thumbnailStreamId = localPlaylistManager
.getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid());
localPlaylistManager
.changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
};
new AlertDialog.Builder(activity)
.setItems(items.toArray(new String[0]), action)
.show();
}
private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
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.getUid(),
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete, (dialog, which) -> {
showDeleteDialog(selectedItem.name, selectedItem);
dialog.dismiss();
})
.create()
.show();
}

View file

@ -4,6 +4,7 @@ import android.os.Bundle;
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,12 +14,11 @@ import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
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;
import org.schabi.newpipe.util.OnClickGesture;
import java.util.List;
@ -30,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,18 +64,11 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
playlistAdapter = new LocalItemListAdapter(getActivity());
playlistAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
@Override
public void selected(final LocalItem selectedItem) {
if (!(selectedItem instanceof PlaylistMetadataEntry)
|| getStreamEntities() == null) {
return;
}
onPlaylistSelected(
playlistManager,
(PlaylistMetadataEntry) selectedItem,
getStreamEntities()
);
playlistAdapter.setSelectedListener(selectedItem -> {
final List<StreamEntity> entities = getStreamEntities();
if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) {
onPlaylistSelected(playlistManager,
(PlaylistDuplicatesEntry) selectedItem, entities);
}
});
@ -82,10 +76,13 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
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.getDisplayIndexOrderedPlaylists()
playlistDisposables.add(playlistManager
.getPlaylistDuplicates(getStreamEntities().get(0).getUrl())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::onPlaylistsReceived));
}
@ -127,34 +124,50 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
requireDialog().dismiss();
}
private void onPlaylistsReceived(@NonNull final List<PlaylistMetadataEntry> playlists) {
if (playlistAdapter != null && playlistRecyclerView != null) {
private void onPlaylistsReceived(@NonNull final List<PlaylistDuplicatesEntry> 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<PlaylistDuplicatesEntry> 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<StreamEntity> streams) {
if (getStreamEntities() == null) {
return;
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);
}
final Toast successToast = Toast.makeText(getContext(),
R.string.playlist_add_stream_success, Toast.LENGTH_SHORT);
if (playlist.thumbnailUrl.equals("drawable://" + R.drawable.dummy_thumbnail_playlist)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getThumbnailUrl())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> successToast.show()));
}
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.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show()));
}
}));
requireDialog().dismiss();
}

View file

@ -45,8 +45,8 @@ public final class PlaylistCreationDialog extends PlaylistDialog {
return super.onCreateDialog(savedInstanceState);
}
final DialogEditTextBinding dialogBinding
= DialogEditTextBinding.inflate(getLayoutInflater());
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.getRoot().getContext().setTheme(ThemeHelper.getDialogTheme(requireContext()));
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);

View file

@ -9,15 +9,20 @@ import android.view.Window;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.util.StateSaver;
import java.util.List;
import java.util.Objects;
import java.util.Queue;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
@ -131,13 +136,13 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
* @param context context used for accessing the database
* @param streamEntities used for crating the dialog
* @param onExec execution that should occur after a dialog got created, e.g. showing it
* @return Disposable
* @return the disposable that was created
*/
public static Disposable createCorrespondingDialog(
final Context context,
final List<StreamEntity> streamEntities,
final Consumer<PlaylistDialog> onExec
) {
final Consumer<PlaylistDialog> onExec) {
return new LocalPlaylistManager(NewPipeDatabase.getInstance(context))
.hasPlaylists()
.observeOn(AndroidSchedulers.mainThread())
@ -147,4 +152,30 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
: PlaylistCreationDialog.newInstance(streamEntities))
);
}
/**
* Creates a {@link PlaylistAppendDialog} when playlists exists,
* otherwise a {@link PlaylistCreationDialog}. If the player's play queue is null or empty, no
* dialog will be created.
*
* @param player the player from which to extract the context and the play queue
* @param fragmentManager the fragment manager to use to show the dialog
* @return the disposable that was created
*/
public static Disposable showForPlayQueue(
final Player player,
@NonNull final FragmentManager fragmentManager) {
final List<StreamEntity> streamEntities = Stream.of(player.getPlayQueue())
.filter(Objects::nonNull)
.flatMap(playQueue -> playQueue.getStreams().stream())
.map(StreamEntity::new)
.collect(Collectors.toList());
if (streamEntities.isEmpty()) {
return Disposable.empty();
}
return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities,
dialog -> dialog.show(fragmentManager, "PlaylistDialog"));
}
}

View file

@ -41,19 +41,17 @@ class FeedDatabaseManager(context: Context) {
fun database() = database
fun getStreams(
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
getPlayedStreams: Boolean = true
groupId: Long,
includePlayedStreams: Boolean,
includePartiallyPlayedStreams: Boolean,
includeFutureStreams: Boolean
): Maybe<List<StreamWithState>> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> {
if (getPlayedStreams) feedTable.getAllStreams()
else feedTable.getLiveOrNotPlayedStreams()
}
else -> {
if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId)
else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId)
}
}
return feedTable.getStreams(
groupId,
includePlayedStreams,
includePartiallyPlayedStreams,
if (includeFutureStreams) null else OffsetDateTime.now()
)
}
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)

View file

@ -25,7 +25,6 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.os.Bundle
import android.os.Parcelable
@ -37,10 +36,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.AttrRes
import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.edit
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
@ -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,8 @@ 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
import java.util.function.Consumer
@ -98,7 +98,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private var oldestSubscriptionUpdate: OffsetDateTime? = null
private lateinit var groupAdapter: GroupieAdapter
@State @JvmField var showPlayedItems: Boolean = true
private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
private var updateListViewModeOnResume = false
@ -118,7 +117,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
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
}
}
@ -135,9 +134,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
_feedBinding = FragmentFeedBinding.bind(rootView)
super.onViewCreated(rootView, savedInstanceState)
val factory = FeedViewModel.Factory(requireContext(), groupId)
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
val factory = FeedViewModel.getFactory(requireContext(), groupId)
viewModel = ViewModelProvider(this, factory)[FeedViewModel::class.java]
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
groupAdapter = GroupieAdapter().apply {
@ -212,7 +210,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
activity.supportActionBar?.subtitle = groupName
inflater.inflate(R.menu.menu_feed_fragment, menu)
updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items))
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -234,19 +231,42 @@ class FeedFragment : BaseStateFragment<FeedState>() {
}
}
.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)
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
@ -273,14 +293,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
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
)
}
// //////////////////////////////////////////////////////////////////////////
// Handling
// //////////////////////////////////////////////////////////////////////////
@ -381,11 +393,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
@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 }
@ -442,29 +453,38 @@ class FeedFragment : BaseStateFragment<FeedState>() {
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<Throwable>
) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
@ -474,15 +494,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
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) {
@ -499,7 +517,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
message += "\n" + cause.message
}
}
builder.setMessage(message).create().show()
builder.setMessage(message)
.show()
}
private fun updateRelativeTimeViews() {
@ -569,7 +588,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
// state until the user scrolls them out of the visible area which causes a update/bind-call
groupAdapter.notifyItemRangeChanged(
0,
minOf(groupAdapter.itemCount, maxOf(highlightCount, lastNewItemsCount))
highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount)
)
if (highlightCount > 0) {
@ -579,19 +598,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
lastNewItemsCount = highlightCount
}
private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? {
return androidx.core.content.ContextCompat.getDrawable(
context,
android.util.TypedValue().apply {
context.theme.resolveAttribute(
attrResId,
this,
true
)
}.resourceId
)
}
private fun showNewItemsLoaded() {
tryGetNewItemsLoadedButton()?.clearAnimation()
tryGetNewItemsLoadedButton()
@ -601,9 +607,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
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)
}
}
}
)

View file

@ -13,9 +13,9 @@ sealed class FeedState {
data class LoadedState(
val items: List<StreamItem>,
val oldestUpdate: OffsetDateTime? = null,
val oldestUpdate: OffsetDateTime?,
val notLoadedCount: Long,
val itemsErrors: List<Throwable> = emptyList()
val itemsErrors: List<Throwable>
) : FeedState()
data class ErrorState(

View file

@ -1,17 +1,20 @@
package org.schabi.newpipe.local.feed
import android.app.Application
import android.content.Context
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
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.Function4
import io.reactivex.rxjava3.functions.Function6
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.App
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.stream.StreamWithState
@ -26,39 +29,53 @@ import java.time.OffsetDateTime
import java.util.concurrent.TimeUnit
class FeedViewModel(
private val applicationContext: Context,
private val application: Application,
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
initialShowPlayedItems: Boolean = true
initialShowPlayedItems: Boolean,
initialShowPartiallyPlayedItems: Boolean,
initialShowFutureItems: Boolean
) : ViewModel() {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private val feedDatabaseManager = FeedDatabaseManager(application)
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems
private val showPlayedItems = BehaviorProcessor.create<Boolean>()
private val showPlayedItemsFlowable = showPlayedItems
.startWithItem(initialShowPlayedItems)
.distinctUntilChanged()
private val showPartiallyPlayedItems = BehaviorProcessor.create<Boolean>()
private val showPartiallyPlayedItemsFlowable = showPartiallyPlayedItems
.startWithItem(initialShowPartiallyPlayedItems)
.distinctUntilChanged()
private val showFutureItems = BehaviorProcessor.create<Boolean>()
private val showFutureItemsFlowable = showFutureItems
.startWithItem(initialShowFutureItems)
.distinctUntilChanged()
private val mutableStateLiveData = MutableLiveData<FeedState>()
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
private var combineDisposable = Flowable
.combineLatest(
FeedEventManager.events(),
toggleShowPlayedItemsFlowable,
showPlayedItemsFlowable,
showPartiallyPlayedItemsFlowable,
showFutureItemsFlowable,
feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function4 { t1: FeedEventManager.Event, t2: Boolean,
t3: Long, t4: List<OffsetDateTime> ->
return@Function4 CombineResultEventHolder(t1, t2, t3, t4.firstOrNull())
Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean,
t5: Long, t6: List<OffsetDateTime> ->
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, notLoadedCount, oldestUpdate) ->
.map { (event, showPlayedItems, showPartiallyPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) ->
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
feedDatabaseManager
.getStreams(groupId, showPlayedItems)
.getStreams(groupId, showPlayedItems, showPartiallyPlayedItems, showFutureItems)
.blockingGet(arrayListOf())
else
arrayListOf()
@ -69,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)
@ -89,8 +106,10 @@ class FeedViewModel(
private data class CombineResultEventHolder(
val t1: FeedEventManager.Event,
val t2: Boolean,
val t3: Long,
val t4: OffsetDateTime?
val t3: Boolean,
val t4: Boolean,
val t5: Long,
val t6: OffsetDateTime?
)
private data class CombineResultDataHolder(
@ -100,36 +119,60 @@ class FeedViewModel(
val t4: OffsetDateTime?
)
fun togglePlayedItems(showPlayedItems: Boolean) {
toggleShowPlayedItems.onNext(showPlayedItems)
}
fun saveShowPlayedItemsToPreferences(showPlayedItems: Boolean) =
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit {
this.putBoolean(applicationContext.getString(R.string.feed_show_played_items_key), showPlayedItems)
fun setSaveShowPlayedItems(showPlayedItems: Boolean) {
this.showPlayedItems.onNext(showPlayedItems)
PreferenceManager.getDefaultSharedPreferences(application).edit {
this.putBoolean(application.getString(R.string.feed_show_watched_items_key), showPlayedItems)
this.apply()
}
}
fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(applicationContext)
fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(application)
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 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)
class Factory(
private val context: Context,
private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedViewModel(
context.applicationContext,
groupId,
// Read initial value from preferences
getShowPlayedItemsFromPreferences(context.applicationContext)
) as T
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(
App.getApp(),
groupId,
// Read initial value from preferences
getShowPlayedItemsFromPreferences(context.applicationContext),
getShowPartiallyPlayedItemsFromPreferences(context.applicationContext),
getShowFutureItemsFromPreferences(context.applicationContext)
)
}
}
}
}

View file

@ -14,10 +14,12 @@ import org.schabi.newpipe.databinding.ListStreamItemBinding
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
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
@ -40,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)
@ -109,7 +112,7 @@ data class StreamItem(
}
override fun isLongClickable() = when (stream.streamType) {
AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true
AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true
else -> false
}

View file

@ -1,51 +1,54 @@
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
import android.graphics.drawable.Drawable
import android.net.Uri
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.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<Target>()
/**
* 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<StreamInfoItem> = 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)
@ -54,33 +57,105 @@ 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(
PendingIntent.getActivity(
// 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),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
PendingIntent.FLAG_IMMUTABLE
else
0
0,
false
)
)
PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
bitmap?.let { builder.setLargeIcon(it) } // set only if != null
manager.notify(data.pseudoId, builder.build())
// a Target is like a listener for image loading events
val target = object : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
// 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) {
// 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
}
override fun onPrepareLoad(placeHolderDrawable: Drawable) {
// Nothing to do
}
}
// add the target to the list to hold a strong reference and prevent it from being garbage
// collected, since Picasso only holds weak references to targets
iconLoadingTargets.add(target)
PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target)
}
private fun showStreamNotifications(
newStreams: List<StreamInfoItem>,
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 {
@ -101,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<NotificationManager>()!!
val enabled = manager.areNotificationsEnabled()
val channel = manager.getNotificationChannel(channelId)
val importance = channel?.importance

View file

@ -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
},

View file

@ -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)
}

View file

@ -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,17 @@ 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.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
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
@ -75,7 +82,9 @@ 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
)
@ -101,52 +110,7 @@ class FeedLoadManager(private val context: Context) {
.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<StreamInfoItem>
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<FeedUpdateInfo>(wrapper)
}
loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences)
}
.sequential()
.observeOn(AndroidSchedulers.mainThread())
@ -164,7 +128,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<FeedUpdateInfo> {
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<StreamInfoItem>? = null
val errors = ArrayList<Throwable>()
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<StreamInfoItem>()
}
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 +272,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 -> {

View file

@ -19,7 +19,6 @@
package org.schabi.newpipe.local.feed.service
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
@ -30,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
@ -39,8 +39,6 @@ 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 java.util.concurrent.TimeUnit
@ -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<StreamInfoItem>): List<Throwable> {
val toReturn = ArrayList<Throwable>(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,12 +134,8 @@ class FeedLoadService : Service() {
private lateinit var notificationBuilder: NotificationCompat.Builder
private fun createNotification(): NotificationCompat.Builder {
val cancelActionIntent = PendingIntent.getBroadcast(
this,
NOTIFICATION_ID,
Intent(ACTION_CANCEL),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 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)

View file

@ -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<StreamInfoItem>,
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<StreamInfoItem>,
val errors: List<Throwable>,
) {
constructor(
subscription: SubscriptionEntity,
listInfo: ListInfo<StreamInfoItem>,
info: Info,
streams: List<StreamInfoItem>,
errors: List<Throwable>,
) : 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<StreamInfoItem>
}

View file

@ -28,7 +28,6 @@ import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.feed.dao.FeedDAO;
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
@ -51,7 +50,6 @@ import org.schabi.newpipe.util.ExtractorHelper;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import io.reactivex.rxjava3.core.Completable;
@ -89,8 +87,7 @@ public class HistoryRecordManager {
* 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 FeedDAO#getLiveOrNotPlayedStreams
* @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
*/
@ -128,13 +125,11 @@ public class HistoryRecordManager {
// Add a history entry
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry != null) {
streamHistoryTable.delete(latestEntry);
latestEntry.setAccessDate(currentTime);
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
return streamHistoryTable.insert(latestEntry);
if (latestEntry == null) {
// never actually viewed: add history entry but with 0 views
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
} else {
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime));
return 0L;
}
})).subscribeOn(Schedulers.io());
}
@ -155,7 +150,8 @@ public class HistoryRecordManager {
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
return streamHistoryTable.insert(latestEntry);
} else {
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime));
// just viewed for the first time: set 1 view
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1));
}
})).subscribeOn(Schedulers.io());
}
@ -177,10 +173,6 @@ public class HistoryRecordManager {
.subscribeOn(Schedulers.io());
}
public Flowable<List<StreamHistoryEntry>> getStreamHistory() {
return streamHistoryTable.getHistory().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamHistoryEntry>> getStreamHistorySortedById() {
return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io());
}
@ -189,24 +181,6 @@ public class HistoryRecordManager {
return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io());
}
public Single<List<Long>> insertStreamHistory(final Collection<StreamHistoryEntry> entries) {
final List<StreamHistoryEntity> entities = new ArrayList<>(entries.size());
for (final StreamHistoryEntry entry : entries) {
entities.add(entry.toStreamHistoryEntity());
}
return Single.fromCallable(() -> streamHistoryTable.insertAll(entities))
.subscribeOn(Schedulers.io());
}
public Single<Integer> deleteStreamHistory(final Collection<StreamHistoryEntry> entries) {
final List<StreamHistoryEntity> entities = new ArrayList<>(entries.size());
for (final StreamHistoryEntry entry : entries) {
entities.add(entry.toStreamHistoryEntity());
}
return Single.fromCallable(() -> streamHistoryTable.delete(entities))
.subscribeOn(Schedulers.io());
}
private boolean isStreamHistoryEnabled() {
return sharedPreferences.getBoolean(streamHistoryKey, false);
}
@ -260,13 +234,6 @@ public class HistoryRecordManager {
// Stream State History
///////////////////////////////////////////////////////
public Maybe<StreamHistoryEntity> getStreamHistory(final StreamInfo info) {
return Maybe.fromCallable(() -> {
final long streamId = streamTable.upsert(new StreamEntity(info));
return streamHistoryTable.getLatestEntry(streamId);
}).subscribeOn(Schedulers.io());
}
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
return queueItem.getStream()
.map(info -> streamTable.upsert(new StreamEntity(info)))
@ -312,28 +279,6 @@ public class HistoryRecordManager {
}).subscribeOn(Schedulers.io());
}
public Single<List<StreamStateEntity>> loadStreamStateBatch(final List<InfoItem> infos) {
return Single.fromCallable(() -> {
final List<StreamStateEntity> result = new ArrayList<>(infos.size());
for (final InfoItem info : infos) {
final List<StreamEntity> entities = streamTable
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
if (entities.isEmpty()) {
result.add(null);
continue;
}
final List<StreamStateEntity> states = streamStateTable
.getState(entities.get(0).getUid()).blockingFirst();
if (states.isEmpty()) {
result.add(null);
} else {
result.add(states.get(0));
}
}
return result;
}).subscribeOn(Schedulers.io());
}
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
final List<? extends LocalItem> items) {
return Single.fromCallable(() -> {

View file

@ -28,14 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
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;
@ -49,7 +51,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
public class StatisticsPlaylistFragment
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void> {
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void>
implements PlaylistControlViewHolder {
private final CompositeDisposable disposables = new CompositeDisposable();
@State
Parcelable itemsListState;
@ -135,7 +138,7 @@ public class StatisticsPlaylistFragment
protected void initListeners() {
super.initListeners();
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
@ -195,14 +198,9 @@ public class StatisticsPlaylistFragment
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 class StatisticsPlaylistFragment
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();
@ -374,7 +368,7 @@ public class StatisticsPlaylistFragment
}
}
private PlayQueue getPlayQueue() {
public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}

View file

@ -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);
}
}

View file

@ -4,16 +4,19 @@ import android.view.View;
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);
}
@ -39,6 +42,13 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder {
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);
}
}

View file

@ -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);
}
}

View file

@ -11,12 +11,13 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar;
import java.time.format.DateTimeFormatter;
@ -59,7 +60,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
itemAdditionalDetailsView.setText(Localization
.concatenateStrings(item.getStreamEntity().getUploader(),
NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId())));
if (item.getStreamEntity().getDuration() > 0) {
itemDurationView.setText(Localization
@ -68,7 +69,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
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 class LocalPlaylistStreamItemHolder extends LocalItemHolder {
}
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

View file

@ -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);
}
}

View file

@ -11,12 +11,13 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar;
import java.time.format.DateTimeFormatter;
@ -70,11 +71,12 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
final DateTimeFormatter dateTimeFormatter) {
final String watchCount = Localization
.shortViewCount(itemBuilder.getContext(), entry.getWatchCount());
final String uploadDate = dateTimeFormatter.format(entry.getLatestAccessDate());
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
return Localization.concatenateStrings(
// watchCount
Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()),
dateTimeFormatter.format(entry.getLatestAccessDate()),
// serviceName
ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId()));
}
@Override
@ -96,7 +98,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
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
@ -140,7 +143,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
}
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

View file

@ -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);
}
}

View file

@ -5,11 +5,11 @@ import android.view.ViewGroup;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import java.time.format.DateTimeFormatter;
@ -40,9 +40,9 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder {
// Here is where the uploader name is set in the bookmarked playlists library
if (!TextUtils.isEmpty(item.getUploader())) {
itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(),
NewPipe.getNameOfService(item.getServiceId())));
ServiceHelper.getNameOfServiceById(item.getServiceId())));
} else {
itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId()));
itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId()));
}
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);

View file

@ -1,15 +1,16 @@
package org.schabi.newpipe.local.playlist;
import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
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;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@ -32,18 +33,20 @@ import org.schabi.newpipe.R;
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.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.databinding.DialogEditTextBinding;
import org.schabi.newpipe.databinding.LocalPlaylistHeaderBinding;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
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.MainPlayer.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.debounce.DebounceSavable;
@ -51,23 +54,25 @@ import org.schabi.newpipe.util.debounce.DebounceSaver;
import org.schabi.newpipe.util.Localization;
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 org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
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.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void>
implements DebounceSavable {
implements PlaylistControlViewHolder, DebounceSavable {
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
protected Long playlistId;
@ -86,13 +91,19 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private CompositeDisposable disposables;
/* Has the playlist been fully loaded from db */
/** Whether the playlist has been fully loaded from db. */
private AtomicBoolean isLoadingComplete;
/** Used to debounce saving playlist edits to disk. */
private DebounceSaver debounceSaver;
/** Flag to prevent simultaneous rewrites of the playlist. */
private boolean isRewritingPlaylist = false;
/* Is the playlist currently being processed to remove watched videos */
private boolean isRemovingWatched = 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();
@ -161,7 +172,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(itemsList);
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) {
@ -263,14 +274,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
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();
@ -292,6 +299,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
if (disposables != null) {
disposables.dispose();
}
if (tabsPagerAdapter != null) {
tabsPagerAdapter.getLocalPlaylistFragments().remove(this);
}
debounceSaver = null;
playlistManager = null;
@ -305,7 +315,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
///////////////////////////////////////////////////////////////////////////
private Subscriber<List<PlaylistStreamEntry>> getPlaylistObserver() {
return new Subscriber<List<PlaylistStreamEntry>>() {
return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
@ -345,117 +355,148 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.menu_item_remove_watched) {
if (!isRemovingWatched) {
if (item.getItemId() == R.id.menu_item_share_playlist) {
createShareConfirmationDialog();
} else if (item.getItemId() == R.id.menu_item_rename_playlist) {
createRenameDialog();
} else if (item.getItemId() == R.id.menu_item_remove_watched) {
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_rename_playlist) {
createRenameDialog();
} else if (item.getItemId() == R.id.menu_item_remove_duplicates) {
if (!isRewritingPlaylist) {
openRemoveDuplicatesDialog();
}
} else {
return super.onOptionsItemSelected(item);
}
return true;
}
public void removeWatchedStreams(final boolean removePartiallyWatched) {
if (isRemovingWatched) {
return;
}
isRemovingWatched = true;
showLoading();
/**
* 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.
*/
private void sharePlaylist(final boolean shouldSharePlaylistDetails) {
final Context context = requireContext();
disposables.add(playlistManager.getPlaylistStreams(playlistId)
.subscribeOn(Schedulers.io())
.map((List<PlaylistStreamEntry> playlist) -> {
// Playlist data
final Iterator<PlaylistStreamEntry> playlistIter = playlist.iterator();
.flatMapSingle(playlist -> Single.just(playlist.stream()
.map(PlaylistStreamEntry::getStreamEntity)
.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(
context, name, shouldSharePlaylistDetails
? context.getString(R.string.share_playlist_content_details,
name, urlsText) : urlsText),
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
}
// History data
final HistoryRecordManager recordManager
= new HistoryRecordManager(getContext());
final Iterator<StreamHistoryEntry> historyIter = recordManager
.getStreamHistorySortedById().blockingFirst().iterator();
public void removeWatchedStreams(final boolean removePartiallyWatched) {
if (isRewritingPlaylist) {
return;
}
isRewritingPlaylist = true;
showLoading();
final var recordManager = new HistoryRecordManager(getContext());
final var historyIdsMaybe = recordManager.getStreamHistorySortedById()
.firstElement()
// already sorted by ^ getStreamHistorySortedById(), binary search can be used
.map(historyList -> historyList.stream().map(StreamHistoryEntry::getStreamId)
.collect(Collectors.toList()));
final var streamsMaybe = playlistManager.getPlaylistStreams(playlistId)
.firstElement()
.zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> {
// Remove Watched, Functionality data
final List<PlaylistStreamEntry> notWatchedItems = new ArrayList<>();
final List<PlaylistStreamEntry> itemsToKeep = new ArrayList<>();
final boolean isThumbnailPermanent = playlistManager
.getIsPlaylistThumbnailPermanent(playlistId);
boolean thumbnailVideoRemoved = false;
// already sorted by ^ getStreamHistorySortedById(), binary search can be used
final ArrayList<Long> historyStreamIds = new ArrayList<>();
while (historyIter.hasNext()) {
historyStreamIds.add(historyIter.next().getStreamId());
}
if (removePartiallyWatched) {
while (playlistIter.hasNext()) {
final PlaylistStreamEntry playlistItem = playlistIter.next();
for (final var playlistItem : playlist) {
final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
if (indexInHistory < 0) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnailStreamId(playlistId)
== playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
}
} else {
final Iterator<StreamStateEntity> streamStatesIter = recordManager
.loadLocalStreamStateBatch(playlist).blockingGet().iterator();
final var streamStates = recordManager
.loadLocalStreamStateBatch(playlist).blockingGet();
for (int i = 0; i < playlist.size(); i++) {
final var playlistItem = playlist.get(i);
final var streamStateEntity = streamStates.get(i);
while (playlistIter.hasNext()) {
final PlaylistStreamEntry playlistItem = playlistIter.next();
final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
final long duration = playlistItem.toStreamInfoItem().getDuration();
final boolean hasState = streamStatesIter.next() != null;
if (indexInHistory < 0 || hasState) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
if (indexInHistory < 0 || (streamStateEntity != null
&& !streamStateEntity.isFinished(duration))) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnailStreamId(playlistId)
== playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
}
}
return Flowable.just(notWatchedItems, thumbnailVideoRemoved);
})
return new Pair<>(itemsToKeep, thumbnailVideoRemoved);
});
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(flow -> {
final List<PlaylistStreamEntry> notWatchedItems =
(List<PlaylistStreamEntry>) flow.blockingFirst();
final boolean thumbnailVideoRemoved = (Boolean) flow.blockingLast();
final List<PlaylistStreamEntry> itemsToKeep = flow.first;
final boolean thumbnailVideoRemoved = flow.second;
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(notWatchedItems);
itemListAdapter.addItems(itemsToKeep);
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))));
}
@ -479,24 +520,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
itemsListState = null;
}
setVideoCount(itemListAdapter.getItemsList().size());
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
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));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
hideLoading();
}
@ -522,22 +548,21 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return;
}
final DialogEditTextBinding dialogBinding
= DialogEditTextBinding.inflate(getLayoutInflater());
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
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) {
@ -561,8 +586,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
disposables.add(disposable);
}
private void changeThumbnailUrl(final String thumbnailUrl) {
if (playlistManager == null) {
private void changeThumbnailStreamId(final long thumbnailStreamId, final boolean isPermanent) {
if (playlistManager == null || (!isPermanent && playlistManager
.getIsPlaylistThumbnailPermanent(playlistId))) {
return;
}
@ -572,11 +598,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
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)
.changePlaylistThumbnail(playlistId, thumbnailStreamId, isPermanent)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show(), throwable ->
showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
@ -585,16 +611,55 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
private void updateThumbnailUrl() {
final String newThumbnailUrl;
if (!itemListAdapter.getItemsList().isEmpty()) {
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0))
.getStreamEntity().getThumbnailUrl();
} else {
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) {
return;
}
changeThumbnailUrl(newThumbnailUrl);
final long thumbnailStreamId;
if (!itemListAdapter.getItemsList().isEmpty()) {
thumbnailStreamId = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0))
.getStreamEntity().getUid();
} else {
thumbnailStreamId = PlaylistEntity.DEFAULT_THUMBNAIL_ID;
}
changeThumbnailStreamId(thumbnailStreamId, false);
}
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();
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();
hideLoading();
isRewritingPlaylist = false;
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
"Removing duplicated streams"))));
}
private void deleteItem(final PlaylistStreamEntry item) {
@ -603,15 +668,19 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
itemListAdapter.removeItem(item);
if (playlistManager.getPlaylistThumbnail(playlistId)
.equals(item.getStreamEntity().getThumbnailUrl())) {
if (playlistManager.getPlaylistThumbnailStreamId(playlistId) == item.getStreamId()) {
updateThumbnailUrl();
}
setVideoCount(itemListAdapter.getItemsList().size());
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
}
/**
* <p>Commit changes immediately if the playlist has been modified.</p>
* 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) {
@ -739,7 +808,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.setAction(
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
(f, i) ->
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
changeThumbnailStreamId(item.getStreamEntity().getUid(),
true))
.setAction(
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteItem(item))
@ -755,14 +825,24 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
this.name = !TextUtils.isEmpty(title) ? title : "";
}
private void setVideoCount(final long count) {
private void setStreamCountAndOverallDuration(final ArrayList<LocalItem> 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))
);
}
}
private PlayQueue getPlayQueue() {
public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}
@ -780,5 +860,29 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
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;
}
}

View file

@ -3,6 +3,7 @@ package org.schabi.newpipe.local.playlist;
import androidx.annotation.Nullable;
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;
@ -22,6 +23,8 @@ 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;
@ -39,34 +42,37 @@ public class LocalPlaylistManager {
if (streams.isEmpty()) {
return Maybe.empty();
}
final StreamEntity defaultStream = streams.get(0);
// 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.
final PlaylistEntity newPlaylist =
new PlaylistEntity(name, defaultStream.getThumbnailUrl(), -1);
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
final List<Long> streamIds = streamTable.upsertAll(streams);
final PlaylistEntity newPlaylist = new PlaylistEntity(name, false,
streamIds.get(0), -1);
return Maybe.fromCallable(() -> database.runInTransaction(() ->
upsertStreams(playlistTable.insert(newPlaylist), streams, 0))
).subscribeOn(Schedulers.io());
return insertJoinEntities(playlistTable.insert(newPlaylist),
streamIds, 0);
}
)).subscribeOn(Schedulers.io());
}
public Maybe<List<Long>> appendToPlaylist(final long playlistId,
final List<StreamEntity> streams) {
return playlistStreamTable.getMaximumIndexOf(playlistId)
.firstElement()
.map(maxJoinIndex -> database.runInTransaction(() ->
upsertStreams(playlistId, streams, maxJoinIndex + 1))
).subscribeOn(Schedulers.io());
.map(maxJoinIndex -> database.runInTransaction(() -> {
final List<Long> streamIds = streamTable.upsertAll(streams);
return insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1);
}
)).subscribeOn(Schedulers.io());
}
private List<Long> upsertStreams(final long playlistId,
final List<StreamEntity> streams,
final int indexOffset) {
private List<Long> insertJoinEntities(final long playlistId, final List<Long> streamIds,
final int indexOffset) {
final List<PlaylistStreamEntity> joinEntities = new ArrayList<>(streamIds.size());
final List<PlaylistStreamEntity> joinEntities = new ArrayList<>(streams.size());
final List<Long> streamIds = streamTable.upsertAll(streams);
for (int index = 0; index < streamIds.size(); index++) {
joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index),
index + indexOffset));
@ -93,10 +99,10 @@ public class LocalPlaylistManager {
items.add(new PlaylistEntity(item));
}
return Completable.fromRunnable(() -> database.runInTransaction(() -> {
for (final Long uid: deletedItems) {
for (final Long uid : deletedItems) {
playlistTable.deletePlaylist(uid);
}
for (final PlaylistEntity item: items) {
for (final PlaylistEntity item : items) {
playlistTable.upsertPlaylist(item);
}
})).subscribeOn(Schedulers.io());
@ -106,6 +112,23 @@ public class LocalPlaylistManager {
return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistStreamEntry>> 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<List<PlaylistDuplicatesEntry>> getPlaylistDuplicates(final String streamUrl) {
return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl)
.subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistMetadataEntry>> getDisplayIndexOrderedPlaylists() {
return playlistStreamTable.getDisplayIndexOrderedPlaylistMetadata()
.subscribeOn(Schedulers.io());
@ -121,26 +144,42 @@ public class LocalPlaylistManager {
}
public Maybe<Integer> renamePlaylist(final long playlistId, final String name) {
return modifyPlaylist(playlistId, name, null, -1);
return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false, -1);
}
public Maybe<Integer> changePlaylistThumbnail(final long playlistId,
final String thumbnailUrl) {
return modifyPlaylist(playlistId, null, thumbnailUrl, -1);
final long thumbnailStreamId,
final boolean isPermanent) {
return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent, -1);
}
public Maybe<Integer> updatePlaylistDisplayIndex(final long playlistId,
final long displayIndex) {
return modifyPlaylist(playlistId, null, null, displayIndex);
return modifyPlaylist(playlistId, null, THUMBNAIL_ID_LEAVE_UNCHANGED, false, displayIndex);
}
public String getPlaylistThumbnail(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl();
public long getPlaylistThumbnailStreamId(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailStreamId();
}
public boolean getIsPlaylistThumbnailPermanent(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0)
.getIsThumbnailPermanent();
}
public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) {
final long streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId)
.blockingFirst();
if (streamId < 0) {
return PlaylistEntity.DEFAULT_THUMBNAIL_ID;
}
return streamId;
}
private Maybe<Integer> modifyPlaylist(final long playlistId,
@Nullable final String name,
@Nullable final String thumbnailUrl,
final long thumbnailStreamId,
final boolean isPermanent,
final long displayIndex) {
return playlistTable.getPlaylist(playlistId)
.firstElement()
@ -150,8 +189,9 @@ public class LocalPlaylistManager {
if (name != null) {
playlist.setName(name);
}
if (thumbnailUrl != null) {
playlist.setThumbnailUrl(thumbnailUrl);
if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) {
playlist.setThumbnailStreamId(thumbnailStreamId);
playlist.setIsThumbnailPermanent(isPermanent);
}
if (displayIndex != -1) {
playlist.setDisplayIndex(displayIndex);

View file

@ -51,7 +51,8 @@ enum class FeedGroupIcon(
WORLD(34, R.drawable.ic_public),
STAR(35, R.drawable.ic_stars),
SUN(36, R.drawable.ic_wb_sunny),
RSS(37, R.drawable.ic_rss_feed);
RSS(37, R.drawable.ic_rss_feed),
WHATS_NEW(38, R.drawable.ic_subscriptions);
@DrawableRes
fun getDrawableRes(): Int {

View file

@ -1,39 +1,39 @@
package org.schabi.newpipe.local.subscription
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.SubMenu
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.GridLayoutManager
import com.xwray.groupie.Group
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.Item
import com.xwray.groupie.Section
import com.xwray.groupie.viewbinding.GroupieViewHolder
import icepick.State
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID
import org.schabi.newpipe.databinding.DialogTitleBinding
import org.schabi.newpipe.databinding.FeedItemCarouselBinding
import org.schabi.newpipe.databinding.FragmentSubscriptionBinding
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.ktx.animate
@ -41,17 +41,16 @@ import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionS
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
import org.schabi.newpipe.local.subscription.item.ChannelItem
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem
import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewGridItem
import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
import org.schabi.newpipe.local.subscription.item.FeedImportExportItem
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM
import org.schabi.newpipe.local.subscription.item.GroupsHeader
import org.schabi.newpipe.local.subscription.item.Header
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
@ -59,8 +58,8 @@ import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.text.SimpleDateFormat
import java.util.Date
@ -74,13 +73,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private lateinit var subscriptionManager: SubscriptionManager
private val disposables: CompositeDisposable = CompositeDisposable()
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
private val groupAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>()
private val feedGroupsSection = Section()
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
private lateinit var importExportItem: FeedImportExportItem
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
private lateinit var carouselAdapter: GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>
private lateinit var feedGroupsCarousel: FeedGroupCarouselItem
private lateinit var feedGroupsSortMenuItem: GroupsHeader
private val subscriptionsSection = Section()
private val requestExportLauncher =
@ -91,12 +87,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
@State
@JvmField
var itemsListState: Parcelable? = null
@State
@JvmField
var feedGroupsListState: Parcelable? = null
@State
@JvmField
var importExportItemExpandedState: Boolean? = null
var feedGroupsCarouselState: Parcelable? = null
init {
setHasOptionsMenu(true)
@ -106,11 +100,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
// Fragment LifeCycle
// /////////////////////////////////////////////////////////////////////////
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupInitialLayout()
}
override fun onAttach(context: Context) {
super.onAttach(context)
subscriptionManager = SubscriptionManager(requireContext())
@ -120,20 +109,15 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
return inflater.inflate(R.layout.fragment_subscription, container, false)
}
override fun onResume() {
super.onResume()
setupBroadcastReceiver()
}
override fun onPause() {
super.onPause()
itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState()
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
importExportItemExpandedState = importExportItem.isExpanded
feedGroupsCarouselState = feedGroupsCarousel.onSaveInstanceState()
}
if (subscriptionBroadcastReceiver != null && activity != null) {
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
override fun onDestroy() {
@ -150,28 +134,61 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
activity.supportActionBar?.setDisplayShowTitleEnabled(true)
activity.supportActionBar?.setTitle(R.string.tab_subscriptions)
buildImportExportMenu(menu)
}
private fun setupBroadcastReceiver() {
if (activity == null) return
private fun buildImportExportMenu(menu: Menu) {
// -- Import --
val importSubMenu = menu.addSubMenu(R.string.import_from)
if (subscriptionBroadcastReceiver != null) {
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
}
addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { onImportPreviousSelected() }
.setIcon(R.drawable.ic_backup)
val filters = IntentFilter()
filters.addAction(EXPORT_COMPLETE_ACTION)
filters.addAction(IMPORT_COMPLETE_ACTION)
subscriptionBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
_binding?.itemsList?.post {
importExportItem.isExpanded = false
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
}
for (service in ServiceList.all()) {
val subscriptionExtractor = service.subscriptionExtractor ?: continue
val supportedSources = subscriptionExtractor.supportedSources
if (supportedSources.isEmpty()) continue
addMenuItemToSubmenu(importSubMenu, service.serviceInfo.name) {
onImportFromServiceSelected(service.serviceId)
}
.setIcon(ServiceHelper.getIcon(service.serviceId))
}
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
// -- Export --
val exportSubMenu = menu.addSubMenu(R.string.export_to)
addMenuItemToSubmenu(exportSubMenu, R.string.file) { onExportSelected() }
.setIcon(R.drawable.ic_save)
}
private fun addMenuItemToSubmenu(
subMenu: SubMenu,
@StringRes title: Int,
onClick: Runnable
): MenuItem {
return setClickListenerToMenuItem(subMenu.add(title), onClick)
}
private fun addMenuItemToSubmenu(
subMenu: SubMenu,
title: String,
onClick: Runnable
): MenuItem {
return setClickListenerToMenuItem(subMenu.add(title), onClick)
}
private fun setClickListenerToMenuItem(
menuItem: MenuItem,
onClick: Runnable
): MenuItem {
menuItem.setOnMenuItemClickListener {
onClick.run()
true
}
return menuItem
}
private fun onImportFromServiceSelected(serviceId: Int) {
@ -228,63 +245,90 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
// Fragment Views
// ////////////////////////////////////////////////////////////////////////
private fun setupInitialLayout() {
Section().apply {
val carouselAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>()
carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS))
carouselAdapter.add(feedGroupsSection)
carouselAdapter.add(FeedGroupAddItem())
carouselAdapter.setOnItemClickListener { item, _ ->
listenerFeedGroups.selected(item)
}
carouselAdapter.setOnItemLongClickListener { item, _ ->
if (item is FeedGroupCardItem) {
if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) {
return@setOnItemLongClickListener false
}
}
listenerFeedGroups.held(item)
return@setOnItemLongClickListener true
}
feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter)
feedGroupsSortMenuItem = HeaderWithMenuItem(
getString(R.string.feed_groups_header_title),
R.drawable.ic_sort,
menuItemOnClickListener = ::openReorderDialog
)
add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
groupAdapter.add(this)
}
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
subscriptionsSection.setHideWhenEmpty(true)
importExportItem = FeedImportExportItem(
{ onImportPreviousSelected() },
{ onImportFromServiceSelected(it) },
{ onExportSelected() },
importExportItemExpandedState ?: false
)
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
}
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
super.initViews(rootView, savedInstanceState)
_binding = FragmentSubscriptionBinding.bind(rootView)
groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCountChannels(context) else 1
groupAdapter.spanCount = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) getGridSpanCountChannels(context) else 1
binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
spanSizeLookup = groupAdapter.spanSizeLookup
}
binding.itemsList.adapter = groupAdapter
binding.itemsList.itemAnimator = null
viewModel = ViewModelProvider(this).get(SubscriptionViewModel::class.java)
viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java]
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) }
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) { it?.let(this::handleFeedGroups) }
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) {
it?.let { (groups, listViewMode) ->
handleFeedGroups(groups, listViewMode)
}
}
setupInitialLayout()
}
private fun setupInitialLayout() {
Section().apply {
carouselAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>()
carouselAdapter.setOnItemClickListener { item, _ ->
when (item) {
is FeedGroupCardItem ->
NavigationHelper.openFeedFragment(fm, item.groupId, item.name)
is FeedGroupCardGridItem ->
NavigationHelper.openFeedFragment(fm, item.groupId, item.name)
is FeedGroupAddNewItem ->
FeedGroupDialog.newInstance().show(fm, null)
is FeedGroupAddNewGridItem ->
FeedGroupDialog.newInstance().show(fm, null)
}
}
carouselAdapter.setOnItemLongClickListener { item, _ ->
if ((item is FeedGroupCardItem && item.groupId == GROUP_ALL_ID) ||
(item is FeedGroupCardGridItem && item.groupId == GROUP_ALL_ID)
) {
return@setOnItemLongClickListener false
}
when (item) {
is FeedGroupCardItem ->
FeedGroupDialog.newInstance(item.groupId).show(fm, null)
is FeedGroupCardGridItem ->
FeedGroupDialog.newInstance(item.groupId).show(fm, null)
}
return@setOnItemLongClickListener true
}
feedGroupsCarousel = FeedGroupCarouselItem(
carouselAdapter = carouselAdapter,
listViewMode = viewModel.getListViewMode()
)
feedGroupsSortMenuItem = GroupsHeader(
title = getString(R.string.feed_groups_header_title),
onSortClicked = ::openReorderDialog,
onToggleListViewModeClicked = ::toggleListViewMode,
listViewMode = viewModel.getListViewMode(),
)
add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
groupAdapter.clear()
groupAdapter.add(this)
}
subscriptionsSection.setPlaceholder(ImportSubscriptionsHintPlaceholderItem())
subscriptionsSection.setHideWhenEmpty(true)
groupAdapter.add(
Section(
Header(getString(R.string.tab_subscriptions)),
listOf(subscriptionsSection)
)
)
}
private fun toggleListViewMode() {
viewModel.setListViewMode(!viewModel.getListViewMode())
}
private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
@ -297,8 +341,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
val actions = DialogInterface.OnClickListener { _, i ->
when (i) {
0 -> ShareUtils.shareText(
requireContext(), selectedItem.name, selectedItem.url,
selectedItem.thumbnailUrl
requireContext(), selectedItem.name, selectedItem.url, selectedItem.thumbnails
)
1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url)
2 -> deleteChannel(selectedItem)
@ -313,7 +356,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
AlertDialog.Builder(requireContext())
.setCustomTitle(dialogTitleBinding.root)
.setItems(commands, actions)
.create()
.show()
}
@ -328,22 +370,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
override fun doInitialLoadLogic() = Unit
override fun startLoading(forceLoad: Boolean) = Unit
private val listenerFeedGroups = object : OnClickGesture<Item<*>>() {
override fun selected(selectedItem: Item<*>?) {
when (selectedItem) {
is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name)
is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null)
}
}
override fun held(selectedItem: Item<*>?) {
when (selectedItem) {
is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null)
}
}
}
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem>() {
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem> {
override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(
fm,
selectedItem.serviceId, selectedItem.url, selectedItem.name
@ -355,15 +382,15 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
override fun handleResult(result: SubscriptionState) {
super.handleResult(result)
val shouldUseGridLayout = shouldUseGridLayout(context)
when (result) {
is SubscriptionState.LoadedState -> {
result.subscriptions.forEach {
if (it is ChannelItem) {
it.gesturesListener = listenerChannelItem
it.itemVersion = when {
shouldUseGridLayout -> ChannelItem.ItemVersion.GRID
else -> ChannelItem.ItemVersion.MINI
it.itemVersion = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) {
ChannelItem.ItemVersion.GRID
} else {
ChannelItem.ItemVersion.MINI
}
}
}
@ -371,13 +398,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
subscriptionsSection.update(result.subscriptions)
subscriptionsSection.setHideWhenEmpty(false)
if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
binding.itemsList.post {
importExportItem.isExpanded = true
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
}
}
if (itemsListState != null) {
binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState)
itemsListState = null
@ -391,16 +411,38 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
}
}
private fun handleFeedGroups(groups: List<Group>) {
feedGroupsSection.update(groups)
if (feedGroupsListState != null) {
feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState)
feedGroupsListState = null
private fun handleFeedGroups(groups: List<Group>, listViewMode: Boolean) {
if (feedGroupsCarouselState != null) {
feedGroupsCarousel.onRestoreInstanceState(feedGroupsCarouselState)
feedGroupsCarouselState = null
}
feedGroupsSortMenuItem.showMenuItem = groups.size > 1
binding.itemsList.post { feedGroupsSortMenuItem.notifyChanged(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM) }
binding.itemsList.post {
if (context == null) {
// since this part was posted to the next UI cycle, the fragment might have been
// removed in the meantime
return@post
}
feedGroupsCarousel.listViewMode = listViewMode
feedGroupsSortMenuItem.showSortButton = groups.size > 1
feedGroupsSortMenuItem.listViewMode = listViewMode
feedGroupsCarousel.notifyChanged(FeedGroupCarouselItem.PAYLOAD_UPDATE_LIST_VIEW_MODE)
feedGroupsSortMenuItem.notifyChanged(GroupsHeader.PAYLOAD_UPDATE_ICONS)
// update items here to prevent flickering
carouselAdapter.apply {
clear()
if (listViewMode) {
add(FeedGroupAddNewItem())
add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
} else {
add(FeedGroupAddNewGridItem())
add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
}
addAll(groups)
}
}
}
// /////////////////////////////////////////////////////////////////////////

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.local.subscription
import android.content.Context
import android.util.Pair
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
@ -11,12 +12,13 @@ import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.image.ImageStrategy
class SubscriptionManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context)
@ -46,28 +48,38 @@ class SubscriptionManager(context: Context) {
}
}
fun upsertAll(infoList: List<ChannelInfo>): List<SubscriptionEntity> {
fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> {
val listEntities = subscriptionTable.upsertAll(
infoList.map { SubscriptionEntity.from(it) }
infoList.map { SubscriptionEntity.from(it.first) }
)
database.runInTransaction {
infoList.forEachIndexed { index, info ->
feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems)
info.second.forEach {
feedDatabaseManager.upsertAll(
listEntities[index].uid,
it.relatedItems.filterIsInstance<StreamInfoItem>()
)
}
}
}
return listEntities
}
fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url)
.flatMapCompletable {
Completable.fromRunnable {
it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
subscriptionTable.update(it)
feedDatabaseManager.upsertAll(it.uid, info.relatedItems)
fun updateChannelInfo(info: ChannelInfo): Completable =
subscriptionTable.getSubscription(info.serviceId, info.url)
.flatMapCompletable {
Completable.fromRunnable {
it.setData(
info.name,
ImageStrategy.imageListToDbUrl(info.avatars),
info.description,
info.subscriberCount
)
subscriptionTable.update(it)
}
}
}
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
return subscriptionTable().getSubscription(serviceId, url)
@ -84,19 +96,15 @@ class SubscriptionManager(context: Context) {
}
}
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
fun updateFromInfo(info: FeedUpdateInfo) {
val subscriptionEntity = subscriptionTable.getSubscription(info.uid)
if (info is FeedInfo) {
subscriptionEntity.name = info.name
} else if (info is ChannelInfo) {
subscriptionEntity.setData(
info.name,
info.avatarUrl,
info.description,
info.subscriberCount
)
}
subscriptionEntity.name = info.name
subscriptionEntity.avatarUrl = info.avatarUrl
// these two fields are null if the feed info was fetched using the fast feed method
info.description?.let { subscriptionEntity.description = it }
info.subscriberCount?.let { subscriptionEntity.subscriberCount = it }
subscriptionTable.update(subscriptionEntity)
}
@ -107,11 +115,8 @@ class SubscriptionManager(context: Context) {
.observeOn(AndroidSchedulers.mainThread())
}
fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) {
database.runInTransaction {
val subscriptionId = subscriptionTable.insert(subscriptionEntity)
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
}
fun insertSubscription(subscriptionEntity: SubscriptionEntity) {
subscriptionTable.insert(subscriptionEntity)
}
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
@ -125,7 +130,10 @@ class SubscriptionManager(context: Context) {
*/
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
.flatMap { info ->
ExtractorHelper.getChannelTab(subscription.serviceId, info.tabs.first(), false)
}
.map { channel -> channel.relatedItems.filterIsInstance<StreamInfoItem>().map { stream -> StreamEntity(stream) } }
.flatMapCompletable { entities ->
Completable.fromAction {
database.streamDAO().upsertAll(entities)

View file

@ -1,29 +1,51 @@
package org.schabi.newpipe.local.subscription
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.xwray.groupie.Group
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.item.ChannelItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
import java.util.concurrent.TimeUnit
class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
private var subscriptionManager = SubscriptionManager(application)
private val mutableStateLiveData = MutableLiveData<SubscriptionState>()
private val mutableFeedGroupsLiveData = MutableLiveData<List<Group>>()
val stateLiveData: LiveData<SubscriptionState> = mutableStateLiveData
val feedGroupsLiveData: LiveData<List<Group>> = mutableFeedGroupsLiveData
// true -> list view, false -> grid view
private val listViewMode = BehaviorProcessor.createDefault(
!shouldUseGridForSubscription(application)
)
private val listViewModeFlowable = listViewMode.distinctUntilChanged()
private var feedGroupItemsDisposable = feedDatabaseManager.groups()
private val mutableStateLiveData = MutableLiveData<SubscriptionState>()
private val mutableFeedGroupsLiveData = MutableLiveData<Pair<List<Group>, Boolean>>()
val stateLiveData: LiveData<SubscriptionState> = mutableStateLiveData
val feedGroupsLiveData: LiveData<Pair<List<Group>, Boolean>> = mutableFeedGroupsLiveData
private var feedGroupItemsDisposable = Flowable
.combineLatest(
feedDatabaseManager.groups(),
listViewModeFlowable,
::Pair
)
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
.map { it.map(::FeedGroupCardItem) }
.map { (feedGroups, listViewMode) ->
Pair(
feedGroups.map(if (listViewMode) ::FeedGroupCardItem else ::FeedGroupCardGridItem),
listViewMode
)
}
.subscribeOn(Schedulers.io())
.subscribe(
{ mutableFeedGroupsLiveData.postValue(it) },
@ -45,8 +67,38 @@ class SubscriptionViewModel(application: Application) : AndroidViewModel(applica
feedGroupItemsDisposable.dispose()
}
fun setListViewMode(newListViewMode: Boolean) {
listViewMode.onNext(newListViewMode)
}
fun getListViewMode(): Boolean {
return listViewMode.value ?: true
}
sealed class SubscriptionState {
data class LoadedState(val subscriptions: List<Group>) : SubscriptionState()
data class ErrorState(val error: Throwable? = null) : SubscriptionState()
}
companion object {
/**
* Returns whether to use GridLayout mode for Subscription Fragment.
*
* ### Current mapping:
*
* | ItemViewMode | ItemVersion | Span count |
* |---|---|---|
* | AUTO | MINI | 1 |
* | LIST | MINI | 1 |
* | CARD | GRID | > 1 (ThemeHelper defined) |
* | GRID | GRID | > 1 (ThemeHelper defined) |
*
* @see [SubscriptionViewModel.shouldUseGridForSubscription] to modify Layout Manager
*/
fun shouldUseGridForSubscription(context: Context): Boolean {
val itemViewMode = getItemViewMode(context)
return itemViewMode == ItemViewMode.GRID || itemViewMode == ItemViewMode.CARD
}
}
}

View file

@ -1,5 +1,11 @@
package org.schabi.newpipe.local.subscription;
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
@ -40,12 +46,6 @@ import java.util.List;
import icepick.State;
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
public class SubscriptionsImportFragment extends BaseFragment {
@State
int currentServiceId = Constants.NO_SERVICE_ID;
@ -89,7 +89,7 @@ public class SubscriptionsImportFragment extends BaseFragment {
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
ErrorUtil.showSnackbar(activity,
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
NewPipe.getNameOfService(currentServiceId),
ServiceHelper.getNameOfServiceById(currentServiceId),
"Service does not support importing subscriptions",
R.string.general_error));
activity.finish();

View file

@ -1,35 +0,0 @@
package org.schabi.newpipe.local.subscription.decoration
import android.content.Context
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.schabi.newpipe.R
class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() {
private val marginStartEnd: Int
private val marginTopBottom: Int
private val marginBetweenItems: Int
init {
with(context.resources) {
marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin)
marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin)
marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin)
}
}
override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) {
val childAdapterPosition = parent.getChildAdapterPosition(child)
val childAdapterCount = parent.adapter?.itemCount ?: 0
outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom)
if (childAdapterPosition == 0) {
outRect.left = marginStartEnd
} else if (childAdapterPosition == childAdapterCount - 1) {
outRect.right = marginStartEnd
}
}
}

View file

@ -1,7 +1,6 @@
package org.schabi.newpipe.local.subscription.dialog
import android.app.Dialog
import android.content.res.ColorStateList
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
@ -9,12 +8,10 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.ImageViewCompat
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Observer
@ -38,7 +35,7 @@ import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
import org.schabi.newpipe.local.subscription.item.PickerIconItem
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
import org.schabi.newpipe.util.DeviceUtils
@ -58,10 +55,10 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
private var groupSortOrder: Long = -1
sealed class ScreenState : Serializable {
object InitialScreen : ScreenState()
object IconPickerScreen : ScreenState()
object SubscriptionsPickerScreen : ScreenState()
object DeleteScreen : ScreenState()
data object InitialScreen : ScreenState()
data object IconPickerScreen : ScreenState()
data object SubscriptionsPickerScreen : ScreenState()
data object DeleteScreen : ScreenState()
}
@State @JvmField var selectedIcon: FeedGroupIcon? = null
@ -125,21 +122,15 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
_feedGroupCreateBinding = DialogFeedGroupCreateBinding.bind(view)
_searchLayoutBinding = feedGroupCreateBinding.subscriptionsHeaderSearchContainer
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
// KitKat doesn't apply container's theme to <include> content
val contrastColor = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.contrastColor))
searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor)
searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128))
ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor)
}
viewModel = ViewModelProvider(
this,
FeedGroupDialogViewModel.Factory(
FeedGroupDialogViewModel.getFactory(
requireContext(),
groupId, subscriptionsCurrentSearchQuery, subscriptionsShowOnlyUngrouped
groupId,
subscriptionsCurrentSearchQuery,
subscriptionsShowOnlyUngrouped
)
).get(FeedGroupDialogViewModel::class.java)
)[FeedGroupDialogViewModel::class.java]
viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup))
viewModel.subscriptionsLiveData.observe(viewLifecycleOwner) {
@ -347,7 +338,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
if (subscriptions.isEmpty()) {
subscriptionEmptyFooter.clear()
subscriptionEmptyFooter.add(EmptyPlaceholderItem())
subscriptionEmptyFooter.add(ImportSubscriptionsHintPlaceholderItem())
} else {
subscriptionEmptyFooter.clear()
}
@ -379,7 +370,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
private fun setupIconPicker() {
val groupAdapter = GroupieAdapter()
groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(it) })
groupAdapter.addAll(FeedGroupIcon.entries.map { PickerIconItem(it) })
feedGroupCreateBinding.iconSelector.apply {
layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)

View file

@ -4,7 +4,8 @@ import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.Disposable
@ -109,24 +110,24 @@ class FeedGroupDialogViewModel(
}
sealed class DialogEvent {
object ProcessingEvent : DialogEvent()
object SuccessEvent : DialogEvent()
data object ProcessingEvent : DialogEvent()
data object SuccessEvent : DialogEvent()
}
data class Filter(val query: String, val showOnlyUngrouped: Boolean)
class Factory(
private val context: Context,
private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
private val initialQuery: String = "",
private val initialShowOnlyUngrouped: Boolean = false
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedGroupDialogViewModel(
context.applicationContext,
groupId, initialQuery, initialShowOnlyUngrouped
) as T
companion object {
fun getFactory(
context: Context,
groupId: Long,
initialQuery: String,
initialShowOnlyUngrouped: Boolean
) = viewModelFactory {
initializer {
FeedGroupDialogViewModel(
context.applicationContext, groupId, initialQuery, initialShowOnlyUngrouped
)
}
}
}
}

View file

@ -9,7 +9,7 @@ import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.PicassoHelper
import org.schabi.newpipe.util.image.PicassoHelper
class ChannelItem(
private val infoItem: ChannelInfoItem,
@ -39,7 +39,7 @@ class ChannelItem(
itemChannelDescriptionView.text = infoItem.description
}
PicassoHelper.loadThumbnail(infoItem.thumbnailUrl).into(itemThumbnailView)
PicassoHelper.loadAvatar(infoItem.thumbnails).into(itemThumbnailView)
gesturesListener?.run {
viewHolder.root.setOnClickListener { selected(infoItem) }

View file

@ -0,0 +1,14 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FeedGroupAddNewGridItemBinding
class FeedGroupAddNewGridItem : BindableItem<FeedGroupAddNewGridItemBinding>() {
override fun getLayout(): Int = R.layout.feed_group_add_new_grid_item
override fun initializeViewBinding(view: View) = FeedGroupAddNewGridItemBinding.bind(view)
override fun bind(viewBinding: FeedGroupAddNewGridItemBinding, position: Int) {
// this is a static item, nothing to do here
}
}

View file

@ -5,8 +5,10 @@ import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FeedGroupAddNewItemBinding
class FeedGroupAddItem : BindableItem<FeedGroupAddNewItemBinding>() {
class FeedGroupAddNewItem : BindableItem<FeedGroupAddNewItemBinding>() {
override fun getLayout(): Int = R.layout.feed_group_add_new_item
override fun bind(viewBinding: FeedGroupAddNewItemBinding, position: Int) {}
override fun initializeViewBinding(view: View) = FeedGroupAddNewItemBinding.bind(view)
override fun bind(viewBinding: FeedGroupAddNewItemBinding, position: Int) {
// this is a static item, nothing to do here
}
}

View file

@ -0,0 +1,32 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.databinding.FeedGroupCardGridItemBinding
import org.schabi.newpipe.local.subscription.FeedGroupIcon
data class FeedGroupCardGridItem(
val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
val name: String,
val icon: FeedGroupIcon,
) : BindableItem<FeedGroupCardGridItemBinding>() {
constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)
override fun getId(): Long {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> super.getId()
else -> groupId
}
}
override fun getLayout(): Int = R.layout.feed_group_card_grid_item
override fun bind(viewBinding: FeedGroupCardGridItemBinding, position: Int) {
viewBinding.title.text = name
viewBinding.icon.setImageResource(icon.getDrawableRes())
}
override fun initializeViewBinding(view: View) = FeedGroupCardGridItemBinding.bind(view)
}

View file

@ -1,60 +1,82 @@
package org.schabi.newpipe.local.subscription.item
import android.content.Context
import android.os.Parcelable
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.viewbinding.BindableItem
import com.xwray.groupie.viewbinding.GroupieViewHolder
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FeedItemCarouselBinding
import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount
class FeedGroupCarouselItem(
context: Context,
private val carouselAdapter: GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>
private val carouselAdapter: GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>,
var listViewMode: Boolean
) : BindableItem<FeedItemCarouselBinding>() {
private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context)
companion object {
const val PAYLOAD_UPDATE_LIST_VIEW_MODE = 2
}
private var linearLayoutManager: LinearLayoutManager? = null
private var carouselLayoutManager: LinearLayoutManager? = null
private var listState: Parcelable? = null
override fun getLayout() = R.layout.feed_item_carousel
fun onSaveInstanceState(): Parcelable? {
listState = linearLayoutManager?.onSaveInstanceState()
listState = carouselLayoutManager?.onSaveInstanceState()
return listState
}
fun onRestoreInstanceState(state: Parcelable?) {
linearLayoutManager?.onRestoreInstanceState(state)
carouselLayoutManager?.onRestoreInstanceState(state)
listState = state
}
override fun initializeViewBinding(view: View): FeedItemCarouselBinding {
val viewHolder = FeedItemCarouselBinding.bind(view)
val viewBinding = FeedItemCarouselBinding.bind(view)
updateViewMode(viewBinding)
return viewBinding
}
linearLayoutManager = LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
viewHolder.recyclerView.apply {
layoutManager = linearLayoutManager
adapter = carouselAdapter
addItemDecoration(feedGroupCarouselDecoration)
override fun bind(
viewBinding: FeedItemCarouselBinding,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.contains(PAYLOAD_UPDATE_LIST_VIEW_MODE)) {
updateViewMode(viewBinding)
return
}
return viewHolder
super.bind(viewBinding, position, payloads)
}
override fun bind(viewBinding: FeedItemCarouselBinding, position: Int) {
viewBinding.recyclerView.apply { adapter = carouselAdapter }
linearLayoutManager?.onRestoreInstanceState(listState)
carouselLayoutManager?.onRestoreInstanceState(listState)
}
override fun unbind(viewHolder: GroupieViewHolder<FeedItemCarouselBinding>) {
super.unbind(viewHolder)
listState = carouselLayoutManager?.onSaveInstanceState()
}
listState = linearLayoutManager?.onSaveInstanceState()
private fun updateViewMode(viewBinding: FeedItemCarouselBinding) {
viewBinding.recyclerView.apply { adapter = carouselAdapter }
val context = viewBinding.root.context
carouselLayoutManager = if (listViewMode) {
LinearLayoutManager(context)
} else {
GridLayoutManager(context, getGridSpanCount(context, DeviceUtils.dpToPx(112, context)))
}
viewBinding.recyclerView.apply {
layoutManager = carouselLayoutManager
adapter = carouselAdapter
}
}
}

View file

@ -1,122 +0,0 @@
package org.schabi.newpipe.local.subscription.item
import android.graphics.Color
import android.graphics.PorterDuff
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import com.xwray.groupie.viewbinding.BindableItem
import com.xwray.groupie.viewbinding.GroupieViewHolder
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FeedImportExportGroupBinding
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.ktx.animateRotation
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.views.CollapsibleView
class FeedImportExportItem(
val onImportPreviousSelected: () -> Unit,
val onImportFromServiceSelected: (Int) -> Unit,
val onExportSelected: () -> Unit,
var isExpanded: Boolean = false
) : BindableItem<FeedImportExportGroupBinding>() {
companion object {
const val REFRESH_EXPANDED_STATUS = 123
}
override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int, payloads: MutableList<Any>) {
if (payloads.contains(REFRESH_EXPANDED_STATUS)) {
viewBinding.importExportOptions.apply { if (isExpanded) expand() else collapse() }
return
}
super.bind(viewBinding, position, payloads)
}
override fun getLayout(): Int = R.layout.feed_import_export_group
override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int) {
if (viewBinding.importFromOptions.childCount == 0) setupImportFromItems(viewBinding.importFromOptions)
if (viewBinding.exportToOptions.childCount == 0) setupExportToItems(viewBinding.exportToOptions)
expandIconListener?.let { viewBinding.importExportOptions.removeListener(it) }
expandIconListener = CollapsibleView.StateListener { newState ->
viewBinding.importExportExpandIcon.animateRotation(
250, if (newState == CollapsibleView.COLLAPSED) 0 else 180
)
}
viewBinding.importExportOptions.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED
viewBinding.importExportExpandIcon.rotation = if (isExpanded) 180F else 0F
viewBinding.importExportOptions.ready()
viewBinding.importExportOptions.addListener(expandIconListener)
viewBinding.importExport.setOnClickListener {
viewBinding.importExportOptions.switchState()
isExpanded = viewBinding.importExportOptions.currentState == CollapsibleView.EXPANDED
}
}
override fun unbind(viewHolder: GroupieViewHolder<FeedImportExportGroupBinding>) {
super.unbind(viewHolder)
expandIconListener?.let { viewHolder.binding.importExportOptions.removeListener(it) }
expandIconListener = null
}
override fun initializeViewBinding(view: View) = FeedImportExportGroupBinding.bind(view)
private var expandIconListener: CollapsibleView.StateListener? = null
private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View {
val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null)
val titleView = itemRoot.findViewById<TextView>(android.R.id.text1)
val iconView = itemRoot.findViewById<ImageView>(android.R.id.icon1)
titleView.text = title
iconView.setImageResource(icon)
container.addView(itemRoot)
return itemRoot
}
private fun setupImportFromItems(listHolder: ViewGroup) {
val previousBackupItem = addItemView(
listHolder.context.getString(R.string.previous_export),
R.drawable.ic_backup, listHolder
)
previousBackupItem.setOnClickListener { onImportPreviousSelected() }
val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE
val services = listHolder.context.resources.getStringArray(R.array.service_list)
for (serviceName in services) {
try {
val service = NewPipe.getService(serviceName)
val subscriptionExtractor = service.subscriptionExtractor ?: continue
val supportedSources = subscriptionExtractor.supportedSources
if (supportedSources.isEmpty()) continue
val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder)
val iconView = itemView.findViewById<ImageView>(android.R.id.icon1)
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) }
} catch (e: ExtractionException) {
throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e)
}
}
}
private fun setupExportToItems(listHolder: ViewGroup) {
val previousBackupItem = addItemView(
listHolder.context.getString(R.string.file),
R.drawable.ic_save, listHolder
)
previousBackupItem.setOnClickListener { onExportSelected() }
}
}

View file

@ -0,0 +1,50 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View
import androidx.core.view.isVisible
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.SubscriptionGroupsHeaderBinding
class GroupsHeader(
private val title: String,
private val onSortClicked: () -> Unit,
private val onToggleListViewModeClicked: () -> Unit,
var showSortButton: Boolean = true,
var listViewMode: Boolean = true
) : BindableItem<SubscriptionGroupsHeaderBinding>() {
companion object {
const val PAYLOAD_UPDATE_ICONS = 1
}
override fun getLayout(): Int = R.layout.subscription_groups_header
override fun bind(
viewBinding: SubscriptionGroupsHeaderBinding,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.contains(PAYLOAD_UPDATE_ICONS)) {
updateIcons(viewBinding)
return
}
super.bind(viewBinding, position, payloads)
}
override fun bind(viewBinding: SubscriptionGroupsHeaderBinding, position: Int) {
viewBinding.headerTitle.text = title
viewBinding.headerSort.setOnClickListener { onSortClicked() }
viewBinding.headerToggleViewMode.setOnClickListener { onToggleListViewModeClicked() }
updateIcons(viewBinding)
}
override fun initializeViewBinding(view: View) = SubscriptionGroupsHeaderBinding.bind(view)
private fun updateIcons(viewBinding: SubscriptionGroupsHeaderBinding) {
viewBinding.headerToggleViewMode.setImageResource(
if (listViewMode) R.drawable.ic_apps else R.drawable.ic_list
)
viewBinding.headerSort.isVisible = showSortButton
}
}

View file

@ -0,0 +1,17 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.SubscriptionHeaderBinding
class Header(private val title: String) : BindableItem<SubscriptionHeaderBinding>() {
override fun getLayout(): Int = R.layout.subscription_header
override fun bind(viewBinding: SubscriptionHeaderBinding, position: Int) {
viewBinding.root.text = title
}
override fun initializeViewBinding(view: View) = SubscriptionHeaderBinding.bind(view)
}

View file

@ -1,50 +0,0 @@
package org.schabi.newpipe.local.subscription.item
import android.view.View
import android.view.View.OnClickListener
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.HeaderWithMenuItemBinding
class HeaderWithMenuItem(
val title: String,
@DrawableRes val itemIcon: Int = 0,
var showMenuItem: Boolean = true,
private val onClickListener: (() -> Unit)? = null,
private val menuItemOnClickListener: (() -> Unit)? = null
) : BindableItem<HeaderWithMenuItemBinding>() {
companion object {
const val PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM = 1
}
override fun getLayout(): Int = R.layout.header_with_menu_item
override fun bind(viewBinding: HeaderWithMenuItemBinding, position: Int, payloads: MutableList<Any>) {
if (payloads.contains(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM)) {
updateMenuItemVisibility(viewBinding)
return
}
super.bind(viewBinding, position, payloads)
}
override fun bind(viewBinding: HeaderWithMenuItemBinding, position: Int) {
viewBinding.headerTitle.text = title
viewBinding.headerMenuItem.setImageResource(itemIcon)
val listener = onClickListener?.let { OnClickListener { onClickListener.invoke() } }
viewBinding.root.setOnClickListener(listener)
val menuItemListener = menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } }
viewBinding.headerMenuItem.setOnClickListener(menuItemListener)
updateMenuItemVisibility(viewBinding)
}
override fun initializeViewBinding(view: View) = HeaderWithMenuItemBinding.bind(view)
private fun updateMenuItemVisibility(viewBinding: HeaderWithMenuItemBinding) {
viewBinding.headerMenuItem.isVisible = showMenuItem
}
}

View file

@ -5,8 +5,11 @@ import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ListEmptyViewBinding
class EmptyPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
override fun getLayout(): Int = R.layout.list_empty_view
/**
* When there are no subscriptions, show a hint to the user about how to import subscriptions
*/
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
override fun getLayout(): Int = R.layout.list_empty_view_subscriptions
override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {}
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view)

View file

@ -10,7 +10,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.util.PicassoHelper
import org.schabi.newpipe.util.image.PicassoHelper
data class PickerSubscriptionItem(
val subscriptionEntity: SubscriptionEntity,

View file

@ -19,10 +19,13 @@
package org.schabi.newpipe.local.subscription.services;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber;
@ -43,8 +46,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.MainActivity.DEBUG;
public class SubscriptionsExportService extends BaseImportExportService {
public static final String KEY_FILE_PATH = "key_file_path";
@ -65,7 +66,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
return START_NOT_STICKY;
}
final Uri path = intent.getParcelableExtra(KEY_FILE_PATH);
final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class);
if (path == null) {
stopAndReportError(new IllegalStateException(
"Exporting to a file, but the path is null"),
@ -109,8 +110,8 @@ public class SubscriptionsExportService extends BaseImportExportService {
subscriptionManager.subscriptionTable().getAll().take(1)
.map(subscriptionEntities -> {
final List<SubscriptionItem> result
= new ArrayList<>(subscriptionEntities.size());
final List<SubscriptionItem> result =
new ArrayList<>(subscriptionEntities.size());
for (final SubscriptionEntity entity : subscriptionEntities) {
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(),
entity.getName()));

View file

@ -26,9 +26,11 @@ import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber;
@ -38,6 +40,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.streams.io.SharpInputStream;
@ -48,6 +51,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@ -105,7 +109,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
if (currentMode == CHANNEL_URL_MODE) {
channelUrl = intent.getStringExtra(KEY_VALUE);
} else {
final Uri uri = intent.getParcelableExtra(KEY_VALUE);
final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class);
if (uri == null) {
stopAndReportError(new IllegalStateException(
"Importing from input stream, but file path is null"),
@ -199,12 +203,19 @@ public class SubscriptionsImportService extends BaseImportExportService {
.parallel(PARALLEL_EXTRACTIONS)
.runOn(Schedulers.io())
.map((Function<SubscriptionItem, Notification<ChannelInfo>>) subscriptionItem -> {
.map((Function<SubscriptionItem, Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>>) subscriptionItem -> {
try {
return Notification.createOnNext(ExtractorHelper
final ChannelInfo channelInfo = ExtractorHelper
.getChannelInfo(subscriptionItem.getServiceId(),
subscriptionItem.getUrl(), true)
.blockingGet());
.blockingGet();
return Notification.createOnNext(new Pair<>(channelInfo,
Collections.singletonList(
ExtractorHelper.getChannelTab(
subscriptionItem.getServiceId(),
channelInfo.getTabs().get(0), true).blockingGet()
)));
} catch (final Throwable e) {
return Notification.createOnError(e);
}
@ -223,7 +234,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
}
private Subscriber<List<SubscriptionEntity>> getSubscriber() {
return new Subscriber<List<SubscriptionEntity>>() {
return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
subscription = s;
@ -254,10 +265,11 @@ public class SubscriptionsImportService extends BaseImportExportService {
};
}
private Consumer<Notification<ChannelInfo>> getNotificationsConsumer() {
private Consumer<Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>> getNotificationsConsumer() {
return notification -> {
if (notification.isOnNext()) {
final String name = notification.getValue().getName();
final String name = notification.getValue().first.getName();
eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : "");
} else if (notification.isOnError()) {
final Throwable error = notification.getError();
@ -275,10 +287,12 @@ public class SubscriptionsImportService extends BaseImportExportService {
};
}
private Function<List<Notification<ChannelInfo>>, List<SubscriptionEntity>> upsertBatch() {
private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>,
List<SubscriptionEntity>> upsertBatch() {
return notificationList -> {
final List<ChannelInfo> infoList = new ArrayList<>(notificationList.size());
for (final Notification<ChannelInfo> n : notificationList) {
final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList =
new ArrayList<>(notificationList.size());
for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) {
if (n.isOnNext()) {
infoList.add(n.getValue());
}