Merge branch 'dev' into pr8221
This commit is contained in:
commit
e1ce3fef1b
1741 changed files with 56947 additions and 21110 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() -> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue