onCompletion) {
- disposables.clear();
- if (commentText != null) {
- TextLinkifier.fromDescription(itemContentView, commentText,
- HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
- onCompletion);
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
index a84c98404..80f62eed3 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
@@ -12,10 +12,6 @@ import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization;
-import androidx.preference.PreferenceManager;
-
-import static org.schabi.newpipe.MainActivity.DEBUG;
-
/*
* Created by Christian Schabesberger on 01.08.16.
*
@@ -81,7 +77,9 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
}
}
- final String uploadDate = getFormattedRelativeUploadDate(infoItem);
+ final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(),
+ infoItem.getUploadDate(),
+ infoItem.getTextualUploadDate());
if (!TextUtils.isEmpty(uploadDate)) {
if (viewsAndDate.isEmpty()) {
return uploadDate;
@@ -92,20 +90,4 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
return viewsAndDate;
}
-
- private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) {
- if (infoItem.getUploadDate() != null) {
- String formattedRelativeTime = Localization
- .relativeTime(infoItem.getUploadDate().offsetDateTime());
-
- if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext())
- .getBoolean(itemBuilder.getContext()
- .getString(R.string.show_original_time_ago_key), false)) {
- formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")";
- }
- return formattedRelativeTime;
- } else {
- return infoItem.getTextualUploadDate();
- }
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
new file mode 100644
index 000000000..61721d546
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
@@ -0,0 +1,9 @@
+package org.schabi.newpipe.ktx
+
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.core.os.BundleCompat
+
+inline fun Bundle.parcelableArrayList(key: String?): ArrayList? {
+ return BundleCompat.getParcelableArrayList(this, key, T::class.java)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
index b9409cb9d..b33619dea 100644
--- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
@@ -14,6 +14,7 @@ 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;
@@ -24,6 +25,7 @@ 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;
@@ -73,10 +75,12 @@ public class LocalItemListAdapter extends RecyclerView.Adapter localItems;
@@ -87,6 +91,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter, Void> {
+public final class BookmarkFragment extends BaseLocalListFragment, Void>
+ implements DebounceSavable {
+
+ private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
- protected Parcelable itemsListState;
+ Parcelable itemsListState;
private Subscription databaseSubscription;
private CompositeDisposable disposables = new CompositeDisposable();
private LocalPlaylistManager localPlaylistManager;
private RemotePlaylistManager remotePlaylistManager;
+ private ItemTouchHelper itemTouchHelper;
+
+ /* Have the bookmarked playlists been fully loaded from db */
+ private AtomicBoolean isLoadingComplete;
+
+ /* Gives enough time to avoid interrupting user sorting operations */
+ @Nullable
+ private DebounceSaver debounceSaver;
+
+ private List> deletedItems;
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Creation
@@ -65,6 +86,11 @@ public final class BookmarkFragment extends BaseLocalListFragment();
}
@Nullable
@@ -91,10 +117,20 @@ public final class BookmarkFragment extends BaseLocalListFragment() {
@Override
public void selected(final LocalItem selectedItem) {
@@ -102,7 +138,7 @@ public final class BookmarkFragment extends BaseLocalListFragment> getPlaylistsSubscriber() {
- return new Subscriber>() {
+ return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
+ isLoadingComplete.set(false);
+
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
@@ -196,7 +258,10 @@ public final class BookmarkFragment extends BaseLocalListFragment subscriptions) {
- handleResult(subscriptions);
+ if (debounceSaver == null || !debounceSaver.getIsModified()) {
+ handleResult(subscriptions);
+ isLoadingComplete.set(true);
+ }
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
@@ -209,7 +274,8 @@ public final class BookmarkFragment extends BaseLocalListFragment { /*Do nothing on success*/ }, throwable -> showError(
+ new ErrorInfo(throwable,
+ UserAction.REQUESTED_BOOKMARK,
+ "Changing playlist name")));
+ disposables.add(disposable);
+ }
+
+ private void deleteItem(final PlaylistLocalItem item) {
+ if (itemListAdapter == null) {
+ return;
+ }
+ itemListAdapter.removeItem(item);
+
+ if (item instanceof PlaylistMetadataEntry) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM));
+ } else if (item instanceof PlaylistRemoteEntity) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM));
+ }
+
+ if (debounceSaver != null) {
+ debounceSaver.setHasChangesToSave();
+ saveImmediate();
+ }
+ }
+
+ @Override
+ public void saveImmediate() {
+ if (itemListAdapter == null) {
+ return;
+ }
+
+ // List must be loaded and modified in order to save
+ if (isLoadingComplete == null || debounceSaver == null
+ || !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
+ return;
+ }
+
+ final List items = itemListAdapter.getItemsList();
+ final List localItemsUpdate = new ArrayList<>();
+ final List localItemsDeleteUid = new ArrayList<>();
+ final List remoteItemsUpdate = new ArrayList<>();
+ final List remoteItemsDeleteUid = new ArrayList<>();
+
+ // Calculate display index
+ for (int i = 0; i < items.size(); i++) {
+ final LocalItem item = items.get(i);
+
+ if (item instanceof PlaylistMetadataEntry
+ && ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
+ ((PlaylistMetadataEntry) item).setDisplayIndex(i);
+ localItemsUpdate.add((PlaylistMetadataEntry) item);
+ } else if (item instanceof PlaylistRemoteEntity
+ && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
+ ((PlaylistRemoteEntity) item).setDisplayIndex(i);
+ remoteItemsUpdate.add((PlaylistRemoteEntity) item);
+ }
+ }
+
+ // Find deleted items
+ for (final Pair item : deletedItems) {
+ if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) {
+ localItemsDeleteUid.add(item.first);
+ } else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) {
+ remoteItemsDeleteUid.add(item.first);
+ }
+ }
+
+ deletedItems.clear();
+
+ // 1. Update local playlists
+ // 2. Update remote playlists
+ // 3. Set NoChangesToSave
+ disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid)
+ .mergeWith(remotePlaylistManager.updatePlaylists(
+ remoteItemsUpdate, remoteItemsDeleteUid))
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(() -> {
+ if (debounceSaver != null) {
+ debounceSaver.setNoChangesToSave();
+ }
+ },
+ throwable -> showError(new ErrorInfo(throwable,
+ UserAction.REQUESTED_BOOKMARK, "Saving playlist"))
+ ));
+
+ }
+
+ private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
+ // if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
+ // with an `if (shouldUseGridLayout()) ...`
+ return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
+ ItemTouchHelper.ACTION_STATE_IDLE) {
+ @Override
+ public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
+ final int viewSize,
+ final int viewSizeOutOfBounds,
+ final int totalSize,
+ final long msSinceStartScroll) {
+ final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView,
+ viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
+ final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY,
+ Math.abs(standardSpeed));
+ return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds);
+ }
+
+ @Override
+ public boolean onMove(@NonNull final RecyclerView recyclerView,
+ @NonNull final RecyclerView.ViewHolder source,
+ @NonNull final RecyclerView.ViewHolder target) {
+
+ // Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder.
+ if (itemListAdapter == null
+ || source.getItemViewType() != target.getItemViewType()
+ && !(
+ (
+ (source instanceof LocalBookmarkPlaylistItemHolder)
+ || (source instanceof RemoteBookmarkPlaylistItemHolder)
+ )
+ && (
+ (target instanceof LocalBookmarkPlaylistItemHolder)
+ || (target instanceof RemoteBookmarkPlaylistItemHolder)
+ ))
+ ) {
+ return false;
+ }
+
+ final int sourceIndex = source.getBindingAdapterPosition();
+ final int targetIndex = target.getBindingAdapterPosition();
+ final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
+ if (isSwapped && debounceSaver != null) {
+ debounceSaver.setHasChangesToSave();
+ }
+ return isSwapped;
+ }
+
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean isItemViewSwipeEnabled() {
+ return false;
+ }
+
+ @Override
+ public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
+ final int swipeDir) {
+ // Do nothing.
+ }
+ };
+ }
+
///////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
- showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid()));
+ showDeleteDialog(item.getName(), item);
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
@@ -257,7 +494,7 @@ public final class BookmarkFragment extends BaseLocalListFragment items = new ArrayList<>();
items.add(rename);
@@ -270,13 +507,12 @@ public final class BookmarkFragment extends BaseLocalListFragment
changeLocalPlaylistName(
- selectedItem.uid,
+ selectedItem.getUid(),
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
.show();
}
- private void showDeleteDialog(final String name, final Single deleteReactor) {
+ private void showDeleteDialog(final String name, final PlaylistLocalItem item) {
if (activity == null || disposables == null) {
return;
}
@@ -313,35 +549,8 @@ public final class BookmarkFragment extends BaseLocalListFragment
- disposables.add(deleteReactor
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> { /*Do nothing on success*/ }, throwable ->
- showError(new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Deleting playlist")))))
+ .setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item))
.setNegativeButton(R.string.cancel, null)
.show();
}
-
- private void changeLocalPlaylistName(final long id, final String name) {
- if (localPlaylistManager == null) {
- return;
- }
-
- if (DEBUG) {
- Log.d(TAG, "Updating playlist id=[" + id + "] "
- + "with new name=[" + name + "] items");
- }
-
- localPlaylistManager.renamePlaylist(id, name);
- final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
- new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Changing playlist name")));
- disposables.add(disposable);
- }
}
-
diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
new file mode 100644
index 000000000..25eb2f652
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
@@ -0,0 +1,95 @@
+package org.schabi.newpipe.local.bookmark;
+
+import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
+import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
+import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import io.reactivex.rxjava3.core.Flowable;
+
+/**
+ * Takes care of remote and local playlists at once, hence "merged".
+ */
+public final class MergedPlaylistManager {
+
+ private MergedPlaylistManager() {
+ }
+
+ public static Flowable> getMergedOrderedPlaylists(
+ final LocalPlaylistManager localPlaylistManager,
+ final RemotePlaylistManager remotePlaylistManager) {
+ return Flowable.combineLatest(
+ localPlaylistManager.getPlaylists(),
+ remotePlaylistManager.getPlaylists(),
+ MergedPlaylistManager::merge
+ );
+ }
+
+ /**
+ * Merge localPlaylists and remotePlaylists by the display index.
+ * If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}.
+ *
+ * @param localPlaylists local playlists, already sorted by display index
+ * @param remotePlaylists remote playlists, already sorted by display index
+ * @return merged playlists
+ */
+ public static List merge(
+ final List localPlaylists,
+ final List remotePlaylists) {
+
+ // This algorithm is similar to the merge operation in merge sort.
+ final List result = new ArrayList<>(
+ localPlaylists.size() + remotePlaylists.size());
+ final List itemsWithSameIndex = new ArrayList<>();
+
+ int i = 0;
+ int j = 0;
+ while (i < localPlaylists.size()) {
+ while (j < remotePlaylists.size()) {
+ if (remotePlaylists.get(j).getDisplayIndex()
+ <= localPlaylists.get(i).getDisplayIndex()) {
+ addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
+ j++;
+ } else {
+ break;
+ }
+ }
+ addItem(result, localPlaylists.get(i), itemsWithSameIndex);
+ i++;
+ }
+ while (j < remotePlaylists.size()) {
+ addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
+ j++;
+ }
+ addItemsWithSameIndex(result, itemsWithSameIndex);
+
+ return result;
+ }
+
+ private static void addItem(final List result,
+ final PlaylistLocalItem item,
+ final List itemsWithSameIndex) {
+ if (!itemsWithSameIndex.isEmpty()
+ && itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) {
+ // The new item has a different display index, add previous items with same
+ // index to the result.
+ addItemsWithSameIndex(result, itemsWithSameIndex);
+ itemsWithSameIndex.clear();
+ }
+ itemsWithSameIndex.add(item);
+ }
+
+ private static void addItemsWithSameIndex(final List result,
+ final List itemsWithSameIndex) {
+ Collections.sort(itemsWithSameIndex,
+ Comparator.comparing(PlaylistLocalItem::getOrderingName,
+ Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
+ result.addAll(itemsWithSameIndex);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
index b503c4e05..e7f73079f 100644
--- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
@@ -155,14 +155,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
- playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
+ playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {
successToast.show();
if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
playlistDisposables.add(manager
- .changePlaylistThumbnail(playlist.uid, streams.get(0).getUid(),
+ .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show()));
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
index de640dbbb..a40bf35dc 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
@@ -137,7 +137,7 @@ class NotificationWorker(
.enqueueUniquePeriodicWork(
WORK_TAG,
if (force) {
- ExistingPeriodicWorkPolicy.REPLACE
+ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
} else {
ExistingPeriodicWorkPolicy.KEEP
},
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
index 3d19de9c6..1c2826e7a 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
@@ -26,7 +26,7 @@ object FeedEventManager {
}
sealed class Event {
- object IdleEvent : Event()
+ data object IdleEvent : Event()
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
index 84cd8ed59..b44eec353 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
@@ -18,7 +18,7 @@ data class FeedUpdateInfo(
@NotificationMode
val notificationMode: Int,
val name: String,
- val avatarUrl: String,
+ val avatarUrl: String?,
val url: String,
val serviceId: Int,
// description and subscriberCount are null if the constructor info is from the fast feed method
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java
new file mode 100644
index 000000000..16130009b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java
@@ -0,0 +1,54 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.local.LocalItemBuilder;
+import org.schabi.newpipe.local.history.HistoryRecordManager;
+
+import java.time.format.DateTimeFormatter;
+
+public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder {
+ private final View itemHandleView;
+
+ public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
+ }
+
+ LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
+ final ViewGroup parent) {
+ super(infoItemBuilder, layoutId, parent);
+ itemHandleView = itemView.findViewById(R.id.itemHandle);
+ }
+
+ @Override
+ public void updateFromItem(final LocalItem localItem,
+ final HistoryRecordManager historyRecordManager,
+ final DateTimeFormatter dateTimeFormatter) {
+ if (!(localItem instanceof PlaylistMetadataEntry)) {
+ return;
+ }
+ final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
+
+ itemHandleView.setOnTouchListener(getOnTouchListener(item));
+
+ super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
+ }
+
+ private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) {
+ return (view, motionEvent) -> {
+ view.performClick();
+ if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
+ && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ itemBuilder.getOnItemSelectedListener().drag(item,
+ LocalBookmarkPlaylistItemHolder.this);
+ }
+ return false;
+ };
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java
new file mode 100644
index 000000000..6d61d1e08
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java
@@ -0,0 +1,54 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
+import org.schabi.newpipe.local.LocalItemBuilder;
+import org.schabi.newpipe.local.history.HistoryRecordManager;
+
+import java.time.format.DateTimeFormatter;
+
+public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder {
+ private final View itemHandleView;
+
+ public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
+ }
+
+ RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
+ final ViewGroup parent) {
+ super(infoItemBuilder, layoutId, parent);
+ itemHandleView = itemView.findViewById(R.id.itemHandle);
+ }
+
+ @Override
+ public void updateFromItem(final LocalItem localItem,
+ final HistoryRecordManager historyRecordManager,
+ final DateTimeFormatter dateTimeFormatter) {
+ if (!(localItem instanceof PlaylistRemoteEntity)) {
+ return;
+ }
+ final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
+
+ itemHandleView.setOnTouchListener(getOnTouchListener(item));
+
+ super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
+ }
+
+ private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) {
+ return (view, motionEvent) -> {
+ view.performClick();
+ if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
+ && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ itemBuilder.getOnItemSelectedListener().drag(item,
+ RemoteBookmarkPlaylistItemHolder.this);
+ }
+ return false;
+ };
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
index d14c1a231..765732063 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
@@ -14,6 +14,7 @@ import org.schabi.newpipe.util.ServiceHelper;
import java.time.format.DateTimeFormatter;
public class RemotePlaylistItemHolder extends PlaylistItemHolder {
+
public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, parent);
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
index 51da52ae0..d5ae431fa 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
@@ -49,6 +49,8 @@ import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
+import org.schabi.newpipe.util.debounce.DebounceSavable;
+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;
@@ -58,7 +60,6 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
@@ -68,12 +69,10 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
-import io.reactivex.rxjava3.subjects.PublishSubject;
public class LocalPlaylistFragment extends BaseLocalListFragment, Void>
- implements PlaylistControlViewHolder {
- /** Save the list 10 seconds after the last change occurred. */
- private static final long SAVE_DEBOUNCE_MILLIS = 10000;
+ implements PlaylistControlViewHolder, DebounceSavable {
+
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
protected Long playlistId;
@@ -90,13 +89,12 @@ public class LocalPlaylistFragment extends BaseLocalListFragment debouncedSaveSignal;
private CompositeDisposable disposables;
/** Whether the playlist has been fully loaded from db. */
private AtomicBoolean isLoadingComplete;
- /** Whether the playlist has been modified (e.g. items reordered or deleted) */
- private AtomicBoolean isModified;
+ /** Used to debounce saving playlist edits to disk. */
+ private DebounceSaver debounceSaver;
/** Flag to prevent simultaneous rewrites of the playlist. */
private boolean isRewritingPlaylist = false;
@@ -121,12 +119,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragmentCommit changes immediately if the playlist has been modified.
- * Delete operations and other modifications will be committed to ensure that the database
- * is up to date, e.g. when the user adds the just deleted stream from another fragment.
- */
- public void commitChanges() {
- if (isModified != null && isModified.get()) {
- saveImmediate();
- }
- }
-
@Override
protected void initListeners() {
super.initListeners();
@@ -243,10 +229,13 @@ public class LocalPlaylistFragment extends BaseLocalListFragment streams) {
// Skip handling the result after it has been modified
- if (isModified == null || !isModified.get()) {
+ if (debounceSaver == null || !debounceSaver.getIsModified()) {
handleResult(streams);
isLoadingComplete.set(true);
}
@@ -495,14 +483,14 @@ public class LocalPlaylistFragment extends BaseLocalListFragment {
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
- setVideoCount(itemListAdapter.getItemsList().size());
- saveChanges();
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
+ debounceSaver.setHasChangesToSave();
hideLoading();
isRewritingPlaylist = false;
@@ -684,42 +672,24 @@ public class LocalPlaylistFragment extends BaseLocalListFragment saveImmediate(), throwable ->
- showError(new ErrorInfo(throwable, UserAction.SOMETHING_ELSE,
- "Debounced saver")));
- }
-
- private void saveImmediate() {
+ /**
+ * Commit changes immediately if the playlist has been modified.
+ * Delete operations and other modifications will be committed to ensure that the database
+ * is up to date, e.g. when the user adds the just deleted stream from another fragment.
+ */
+ @Override
+ public void saveImmediate() {
if (playlistManager == null || itemListAdapter == null) {
return;
}
// List must be loaded and modified in order to save
- if (isLoadingComplete == null || isModified == null
- || !isLoadingComplete.get() || !isModified.get()) {
- Log.w(TAG, "Attempting to save playlist when local playlist "
- + "is not loaded or not modified: playlist id=[" + playlistId + "]");
+ if (isLoadingComplete == null || debounceSaver == null
+ || !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
return;
}
@@ -740,8 +710,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment {
- if (isModified != null) {
- isModified.set(false);
+ if (debounceSaver != null) {
+ debounceSaver.setNoChangesToSave();
}
},
throwable -> showError(new ErrorInfo(throwable,
@@ -784,7 +754,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment itemsList) {
if (activity != null && headerBinding != null) {
- headerBinding.playlistStreamCount.setText(Localization
- .localizeStreamCount(activity, count));
+ final long streamCount = itemsList.size();
+ final long playlistOverallDurationSeconds = itemsList.stream()
+ .filter(PlaylistStreamEntry.class::isInstance)
+ .map(PlaylistStreamEntry.class::cast)
+ .map(PlaylistStreamEntry::getStreamEntity)
+ .mapToLong(StreamEntity::getDuration)
+ .sum();
+ headerBinding.playlistStreamCount.setText(
+ Localization.concatenateStrings(
+ Localization.localizeStreamCount(activity, streamCount),
+ Localization.getDurationString(playlistOverallDurationSeconds,
+ true, true))
+ );
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
index 27c148561..dd9307675 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
@@ -19,7 +19,6 @@ import java.util.List;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Maybe;
-import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class LocalPlaylistManager {
@@ -43,10 +42,13 @@ public class LocalPlaylistManager {
return Maybe.empty();
}
+ // Save to the database directly.
+ // Make sure the new playlist is always on the top of bookmark.
+ // The index will be reassigned to non-negative number in BookmarkFragment.
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
final List streamIds = streamTable.upsertAll(streams);
final PlaylistEntity newPlaylist = new PlaylistEntity(name, false,
- streamIds.get(0));
+ streamIds.get(0), -1);
return insertJoinEntities(playlistTable.insert(newPlaylist),
streamIds, 0);
@@ -89,8 +91,20 @@ public class LocalPlaylistManager {
})).subscribeOn(Schedulers.io());
}
- public Flowable> getPlaylists() {
- return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
+ public Completable updatePlaylists(final List updateItems,
+ final List deletedItems) {
+ final List items = new ArrayList<>(updateItems.size());
+ for (final PlaylistMetadataEntry item : updateItems) {
+ items.add(new PlaylistEntity(item));
+ }
+ return Completable.fromRunnable(() -> database.runInTransaction(() -> {
+ for (final Long uid : deletedItems) {
+ playlistTable.deletePlaylist(uid);
+ }
+ for (final PlaylistEntity item : items) {
+ playlistTable.upsertPlaylist(item);
+ }
+ })).subscribeOn(Schedulers.io());
}
public Flowable> getDistinctPlaylistStreams(final long playlistId) {
@@ -110,13 +124,12 @@ public class LocalPlaylistManager {
.subscribeOn(Schedulers.io());
}
- public Flowable> getPlaylistStreams(final long playlistId) {
- return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
+ public Flowable> getPlaylists() {
+ return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
}
- public Single deletePlaylist(final long playlistId) {
- return Single.fromCallable(() -> playlistTable.deletePlaylist(playlistId))
- .subscribeOn(Schedulers.io());
+ public Flowable> getPlaylistStreams(final long playlistId) {
+ return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
}
public Maybe renamePlaylist(final long playlistId, final String name) {
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
index 5221139e3..4cc51f752 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
@@ -7,20 +7,23 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import java.util.List;
+import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class RemotePlaylistManager {
+ private final AppDatabase database;
private final PlaylistRemoteDAO playlistRemoteTable;
public RemotePlaylistManager(final AppDatabase db) {
+ database = db;
playlistRemoteTable = db.playlistRemoteDAO();
}
public Flowable> getPlaylists() {
- return playlistRemoteTable.getAll().subscribeOn(Schedulers.io());
+ return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
}
public Flowable> getPlaylist(final PlaylistInfo info) {
@@ -33,6 +36,18 @@ public class RemotePlaylistManager {
.subscribeOn(Schedulers.io());
}
+ public Completable updatePlaylists(final List updateItems,
+ final List deletedItems) {
+ return Completable.fromRunnable(() -> database.runInTransaction(() -> {
+ for (final Long uid: deletedItems) {
+ playlistRemoteTable.deletePlaylist(uid);
+ }
+ for (final PlaylistRemoteEntity item: updateItems) {
+ playlistRemoteTable.upsert(item);
+ }
+ })).subscribeOn(Schedulers.io());
+ }
+
public Single onBookmark(final PlaylistInfo playlistInfo) {
return Single.fromCallable(() -> {
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
index 488d8b3d2..474add4f4 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
@@ -100,7 +100,9 @@ class SubscriptionManager(context: Context) {
val subscriptionEntity = subscriptionTable.getSubscription(info.uid)
subscriptionEntity.name = info.name
- subscriptionEntity.avatarUrl = info.avatarUrl
+
+ // some services do not provide an avatar URL
+ info.avatarUrl?.let { subscriptionEntity.avatarUrl = it }
// these two fields are null if the feed info was fetched using the fast feed method
info.description?.let { subscriptionEntity.description = it }
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
index 19c581c08..41761fb01 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
@@ -55,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
@@ -370,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)
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
index eff1a4400..292bda394 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
@@ -110,8 +110,8 @@ 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)
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
index d56d16f3c..54809068a 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
@@ -25,6 +25,7 @@ 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;
@@ -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"),
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
index d624e1038..442c7fddb 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
@@ -30,6 +30,7 @@ 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;
@@ -108,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"),
diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt
index 8acd70413..ff0bb269d 100644
--- a/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt
@@ -160,13 +160,12 @@ class MainPlayerGestureListener(
}
override fun onScroll(
- initialEvent: MotionEvent,
+ initialEvent: MotionEvent?,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
-
- if (!playerUi.isFullscreen) {
+ if (initialEvent == null || !playerUi.isFullscreen) {
return false
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt
index 23edcaeb8..0b94bf364 100644
--- a/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt
@@ -167,7 +167,7 @@ class PopupPlayerGestureListener(
}
override fun onFling(
- e1: MotionEvent,
+ e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
@@ -218,11 +218,14 @@ class PopupPlayerGestureListener(
}
override fun onScroll(
- initialEvent: MotionEvent,
+ initialEvent: MotionEvent?,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
+ if (initialEvent == null) {
+ return false
+ }
if (isResizing) {
return super.onScroll(initialEvent, movingEvent, distanceX, distanceY)
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
index 6f76a91d1..737ebc5dd 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
@@ -1,10 +1,12 @@
package org.schabi.newpipe.player.mediasession;
import static org.schabi.newpipe.MainActivity.DEBUG;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
+import android.os.Build;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
@@ -14,15 +16,23 @@ import androidx.annotation.Nullable;
import androidx.media.session.MediaButtonReceiver;
import com.google.android.exoplayer2.ForwardingPlayer;
+import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.R;
+import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.Player;
+import org.schabi.newpipe.player.notification.NotificationActionData;
+import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.player.ui.PlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.StreamTypeUtil;
+import java.util.List;
+import java.util.Objects;
import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
@@ -34,6 +44,10 @@ public class MediaSessionPlayerUi extends PlayerUi
private final String ignoreHardwareMediaButtonsKey;
private boolean shouldIgnoreHardwareMediaButtons = false;
+ // used to check whether any notification action changed, before sending costly updates
+ private List prevNotificationActions = List.of();
+
+
public MediaSessionPlayerUi(@NonNull final Player player) {
super(player);
ignoreHardwareMediaButtonsKey =
@@ -63,6 +77,10 @@ public class MediaSessionPlayerUi extends PlayerUi
sessionConnector.setMetadataDeduplicationEnabled(true);
sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata());
+
+ // force updating media session actions by resetting the previous ones
+ prevNotificationActions = List.of();
+ updateMediaSessionActions();
}
@Override
@@ -80,6 +98,7 @@ public class MediaSessionPlayerUi extends PlayerUi
mediaSession.release();
mediaSession = null;
}
+ prevNotificationActions = List.of();
}
@Override
@@ -163,4 +182,109 @@ public class MediaSessionPlayerUi extends PlayerUi
return builder.build();
}
+
+
+ private void updateMediaSessionActions() {
+ // On Android 13+ (or Android T or API 33+) the actions in the player notification can't be
+ // controlled directly anymore, but are instead derived from custom media session actions.
+ // However the system allows customizing only two of these actions, since the other three
+ // are fixed to play-pause-buffering, previous, next.
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ // Although setting media session actions on older android versions doesn't seem to
+ // cause any trouble, it also doesn't seem to do anything, so we don't do anything to
+ // save battery. Check out NotificationUtil.updateActions() to see what happens on
+ // older android versions.
+ return;
+ }
+
+ // only use the fourth and fifth actions (the settings page also shows only the last 2 on
+ // Android 13+)
+ final List newNotificationActions = IntStream.of(3, 4)
+ .map(i -> player.getPrefs().getInt(
+ player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
+ NotificationConstants.SLOT_DEFAULTS[i]))
+ .mapToObj(action -> NotificationActionData
+ .fromNotificationActionEnum(player, action))
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+
+ // avoid costly notification actions update, if nothing changed from last time
+ if (!newNotificationActions.equals(prevNotificationActions)) {
+ prevNotificationActions = newNotificationActions;
+ sessionConnector.setCustomActionProviders(
+ newNotificationActions.stream()
+ .map(data -> new SessionConnectorActionProvider(data, context))
+ .toArray(SessionConnectorActionProvider[]::new));
+ }
+ }
+
+ @Override
+ public void onBlocked() {
+ super.onBlocked();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPlaying() {
+ super.onPlaying();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onBuffering() {
+ super.onBuffering();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPaused() {
+ super.onPaused();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPausedSeek() {
+ super.onPausedSeek();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onCompleted() {
+ super.onCompleted();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
+ super.onRepeatModeChanged(repeatMode);
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
+ super.onShuffleModeEnabledChanged(shuffleModeEnabled);
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onBroadcastReceived(final Intent intent) {
+ super.onBroadcastReceived(intent);
+ if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) {
+ // the notification actions changed
+ updateMediaSessionActions();
+ }
+ }
+
+ @Override
+ public void onMetadataChanged(@NonNull final StreamInfo info) {
+ super.onMetadataChanged(info);
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPlayQueueEdited() {
+ super.onPlayQueueEdited();
+ updateMediaSessionActions();
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java
new file mode 100644
index 000000000..a5c9fccc9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java
@@ -0,0 +1,47 @@
+package org.schabi.newpipe.player.mediasession;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v4.media.session.PlaybackStateCompat;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
+
+import org.schabi.newpipe.player.notification.NotificationActionData;
+
+import java.lang.ref.WeakReference;
+
+public class SessionConnectorActionProvider implements MediaSessionConnector.CustomActionProvider {
+
+ private final NotificationActionData data;
+ @NonNull
+ private final WeakReference context;
+
+ public SessionConnectorActionProvider(final NotificationActionData notificationActionData,
+ @NonNull final Context context) {
+ this.data = notificationActionData;
+ this.context = new WeakReference<>(context);
+ }
+
+ @Override
+ public void onCustomAction(@NonNull final Player player,
+ @NonNull final String action,
+ @Nullable final Bundle extras) {
+ final Context actualContext = context.get();
+ if (actualContext != null) {
+ actualContext.sendBroadcast(new Intent(action));
+ }
+ }
+
+ @Nullable
+ @Override
+ public PlaybackStateCompat.CustomAction getCustomAction(@NonNull final Player player) {
+ return new PlaybackStateCompat.CustomAction.Builder(
+ data.action(), data.name(), data.icon()
+ ).build();
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java
new file mode 100644
index 000000000..b3abcd0b5
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java
@@ -0,0 +1,187 @@
+package org.schabi.newpipe.player.notification;
+
+import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
+import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.player.Player;
+
+import java.util.Objects;
+
+public final class NotificationActionData {
+
+ @NonNull
+ private final String action;
+ @NonNull
+ private final String name;
+ @DrawableRes
+ private final int icon;
+
+
+ public NotificationActionData(@NonNull final String action, @NonNull final String name,
+ @DrawableRes final int icon) {
+ this.action = action;
+ this.name = name;
+ this.icon = icon;
+ }
+
+ @NonNull
+ public String action() {
+ return action;
+ }
+
+ @NonNull
+ public String name() {
+ return name;
+ }
+
+ @DrawableRes
+ public int icon() {
+ return icon;
+ }
+
+
+ @SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons
+ @Nullable
+ public static NotificationActionData fromNotificationActionEnum(
+ @NonNull final Player player,
+ @NotificationConstants.Action final int selectedAction
+ ) {
+
+ final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
+ final Context ctx = player.getContext();
+
+ switch (selectedAction) {
+ case NotificationConstants.PREVIOUS:
+ return new NotificationActionData(ACTION_PLAY_PREVIOUS,
+ ctx.getString(R.string.exo_controls_previous_description), baseActionIcon);
+
+ case NotificationConstants.NEXT:
+ return new NotificationActionData(ACTION_PLAY_NEXT,
+ ctx.getString(R.string.exo_controls_next_description), baseActionIcon);
+
+ case NotificationConstants.REWIND:
+ return new NotificationActionData(ACTION_FAST_REWIND,
+ ctx.getString(R.string.exo_controls_rewind_description), baseActionIcon);
+
+ case NotificationConstants.FORWARD:
+ return new NotificationActionData(ACTION_FAST_FORWARD,
+ ctx.getString(R.string.exo_controls_fastforward_description),
+ baseActionIcon);
+
+ case NotificationConstants.SMART_REWIND_PREVIOUS:
+ if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
+ return new NotificationActionData(ACTION_PLAY_PREVIOUS,
+ ctx.getString(R.string.exo_controls_previous_description),
+ R.drawable.exo_notification_previous);
+ } else {
+ return new NotificationActionData(ACTION_FAST_REWIND,
+ ctx.getString(R.string.exo_controls_rewind_description),
+ R.drawable.exo_controls_rewind);
+ }
+
+ case NotificationConstants.SMART_FORWARD_NEXT:
+ if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
+ return new NotificationActionData(ACTION_PLAY_NEXT,
+ ctx.getString(R.string.exo_controls_next_description),
+ R.drawable.exo_notification_next);
+ } else {
+ return new NotificationActionData(ACTION_FAST_FORWARD,
+ ctx.getString(R.string.exo_controls_fastforward_description),
+ R.drawable.exo_controls_fastforward);
+ }
+
+ case NotificationConstants.PLAY_PAUSE_BUFFERING:
+ if (player.getCurrentState() == Player.STATE_PREFLIGHT
+ || player.getCurrentState() == Player.STATE_BLOCKED
+ || player.getCurrentState() == Player.STATE_BUFFERING) {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.notification_action_buffering),
+ R.drawable.ic_hourglass_top);
+ }
+
+ // fallthrough
+ case NotificationConstants.PLAY_PAUSE:
+ if (player.getCurrentState() == Player.STATE_COMPLETED) {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.exo_controls_pause_description),
+ R.drawable.ic_replay);
+ } else if (player.isPlaying()
+ || player.getCurrentState() == Player.STATE_PREFLIGHT
+ || player.getCurrentState() == Player.STATE_BLOCKED
+ || player.getCurrentState() == Player.STATE_BUFFERING) {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.exo_controls_pause_description),
+ R.drawable.exo_notification_pause);
+ } else {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.exo_controls_play_description),
+ R.drawable.exo_notification_play);
+ }
+
+ case NotificationConstants.REPEAT:
+ if (player.getRepeatMode() == REPEAT_MODE_ALL) {
+ return new NotificationActionData(ACTION_REPEAT,
+ ctx.getString(R.string.exo_controls_repeat_all_description),
+ R.drawable.exo_media_action_repeat_all);
+ } else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
+ return new NotificationActionData(ACTION_REPEAT,
+ ctx.getString(R.string.exo_controls_repeat_one_description),
+ R.drawable.exo_media_action_repeat_one);
+ } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
+ return new NotificationActionData(ACTION_REPEAT,
+ ctx.getString(R.string.exo_controls_repeat_off_description),
+ R.drawable.exo_media_action_repeat_off);
+ }
+
+ case NotificationConstants.SHUFFLE:
+ if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
+ return new NotificationActionData(ACTION_SHUFFLE,
+ ctx.getString(R.string.exo_controls_shuffle_on_description),
+ R.drawable.exo_controls_shuffle_on);
+ } else {
+ return new NotificationActionData(ACTION_SHUFFLE,
+ ctx.getString(R.string.exo_controls_shuffle_off_description),
+ R.drawable.exo_controls_shuffle_off);
+ }
+
+ case NotificationConstants.CLOSE:
+ return new NotificationActionData(ACTION_CLOSE, ctx.getString(R.string.close),
+ R.drawable.ic_close);
+
+ case NotificationConstants.NOTHING:
+ default:
+ // do nothing
+ return null;
+ }
+ }
+
+
+ @Override
+ public boolean equals(@Nullable final Object obj) {
+ return (obj instanceof NotificationActionData other)
+ && this.action.equals(other.action)
+ && this.name.equals(other.name)
+ && this.icon == other.icon;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(action, name, icon);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java
index 89bf0b22a..b9607f7ea 100644
--- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java
@@ -13,7 +13,7 @@ import org.schabi.newpipe.util.Localization;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
-import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
@@ -65,10 +65,16 @@ public final class NotificationConstants {
public static final int CLOSE = 11;
@Retention(RetentionPolicy.SOURCE)
- @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT,
- PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE})
+ @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
+ SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
+ SHUFFLE, CLOSE})
public @interface Action { }
+ @Action
+ public static final int[] ALL_ACTIONS = {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
+ SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
+ SHUFFLE, CLOSE};
+
@DrawableRes
public static final int[] ACTION_ICONS = {
0,
@@ -95,16 +101,6 @@ public final class NotificationConstants {
CLOSE,
};
- @Action
- public static final int[][] SLOT_ALLOWED_ACTIONS = {
- new int[] {PREVIOUS, REWIND, SMART_REWIND_PREVIOUS},
- new int[] {REWIND, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
- new int[] {NEXT, FORWARD, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
- new int[] {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS,
- SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
- new int[] {NOTHING, NEXT, FORWARD, SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
- };
-
public static final int[] SLOT_PREF_KEYS = {
R.string.notification_slot_0_key,
R.string.notification_slot_1_key,
@@ -165,14 +161,11 @@ public final class NotificationConstants {
/**
* @param context the context to use
* @param sharedPreferences the shared preferences to query values from
- * @param slotCount remove indices >= than this value (set to {@code 5} to do nothing, or make
- * it lower if there are slots with empty actions)
* @return a sorted list of the indices of the slots to use as compact slots
*/
- public static List getCompactSlotsFromPreferences(
+ public static Collection getCompactSlotsFromPreferences(
@NonNull final Context context,
- final SharedPreferences sharedPreferences,
- final int slotCount) {
+ final SharedPreferences sharedPreferences) {
final SortedSet compactSlots = new TreeSet<>();
for (int i = 0; i < 3; i++) {
final int compactSlot = sharedPreferences.getInt(
@@ -180,14 +173,14 @@ public final class NotificationConstants {
if (compactSlot == Integer.MAX_VALUE) {
// settings not yet populated, return default values
- return new ArrayList<>(SLOT_COMPACT_DEFAULTS);
+ return SLOT_COMPACT_DEFAULTS;
}
- // a negative value (-1) is set when the user does not want a particular compact slot
- if (compactSlot >= 0 && compactSlot < slotCount) {
+ if (compactSlot >= 0) {
+ // compact slot is < 0 if there are less than 3 checked checkboxes
compactSlots.add(compactSlot);
}
}
- return new ArrayList<>(compactSlots);
+ return compactSlots;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
index 05c2e3af6..30420b0c7 100644
--- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
@@ -1,16 +1,19 @@
package org.schabi.newpipe.player.notification;
+import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
+import static androidx.media.app.NotificationCompat.MediaStyle;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
+
import android.annotation.SuppressLint;
+import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
import android.os.Build;
import android.util.Log;
-import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.PendingIntentCompat;
@@ -23,23 +26,12 @@ import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
-import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
-import static androidx.media.app.NotificationCompat.MediaStyle;
-import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
-import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
-
/**
* This is a utility class for player notifications.
*/
@@ -100,29 +92,21 @@ public final class NotificationUtil {
final NotificationCompat.Builder builder =
new NotificationCompat.Builder(player.getContext(),
player.getContext().getString(R.string.notification_channel_id));
+ final MediaStyle mediaStyle = new MediaStyle();
- initializeNotificationSlots();
-
- // count the number of real slots, to make sure compact slots indices are not out of bound
- int nonNothingSlotCount = 5;
- if (notificationSlots[3] == NotificationConstants.NOTHING) {
- --nonNothingSlotCount;
+ // setup media style (compact notification slots and media session)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ // notification actions are ignored on Android 13+, and are replaced by code in
+ // MediaSessionPlayerUi
+ final int[] compactSlots = initializeNotificationSlots();
+ mediaStyle.setShowActionsInCompactView(compactSlots);
}
- if (notificationSlots[4] == NotificationConstants.NOTHING) {
- --nonNothingSlotCount;
- }
-
- // build the compact slot indices array (need code to convert from Integer... because Java)
- final List compactSlotList = NotificationConstants.getCompactSlotsFromPreferences(
- player.getContext(), player.getPrefs(), nonNothingSlotCount);
- final int[] compactSlots = compactSlotList.stream().mapToInt(Integer::intValue).toArray();
-
- final MediaStyle mediaStyle = new MediaStyle().setShowActionsInCompactView(compactSlots);
player.UIs()
.get(MediaSessionPlayerUi.class)
.flatMap(MediaSessionPlayerUi::getSessionToken)
.ifPresent(mediaStyle::setMediaSession);
+ // setup notification builder
builder.setStyle(mediaStyle)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@@ -157,7 +141,11 @@ public final class NotificationUtil {
notificationBuilder.setContentText(player.getUploaderName());
notificationBuilder.setTicker(player.getVideoTitle());
- updateActions(notificationBuilder);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ // notification actions are ignored on Android 13+, and are replaced by code in
+ // MediaSessionPlayerUi
+ updateActions(notificationBuilder);
+ }
}
@@ -209,12 +197,35 @@ public final class NotificationUtil {
// ACTIONS
/////////////////////////////////////////////////////
- private void initializeNotificationSlots() {
+ /**
+ * The compact slots array from settings contains indices from 0 to 4, each referring to one of
+ * the five actions configurable by the user. However, if the user sets an action to "Nothing",
+ * then all of the actions coming after will have a "settings index" different than the index
+ * of the corresponding action when sent to the system.
+ *
+ * @return the indices of compact slots referred to the list of non-nothing actions that will be
+ * sent to the system
+ */
+ private int[] initializeNotificationSlots() {
+ final Collection settingsCompactSlots = NotificationConstants
+ .getCompactSlotsFromPreferences(player.getContext(), player.getPrefs());
+ final List adjustedCompactSlots = new ArrayList<>();
+
+ int nonNothingIndex = 0;
for (int i = 0; i < 5; ++i) {
notificationSlots[i] = player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
+
+ if (notificationSlots[i] != NotificationConstants.NOTHING) {
+ if (settingsCompactSlots.contains(i)) {
+ adjustedCompactSlots.add(nonNothingIndex);
+ }
+ nonNothingIndex += 1;
+ }
}
+
+ return adjustedCompactSlots.stream().mapToInt(Integer::intValue).toArray();
}
@SuppressLint("RestrictedApi")
@@ -227,115 +238,15 @@ public final class NotificationUtil {
private void addAction(final NotificationCompat.Builder builder,
@NotificationConstants.Action final int slot) {
- final NotificationCompat.Action action = getAction(slot);
- if (action != null) {
- builder.addAction(action);
+ @Nullable final NotificationActionData data =
+ NotificationActionData.fromNotificationActionEnum(player, slot);
+ if (data == null) {
+ return;
}
- }
- @Nullable
- private NotificationCompat.Action getAction(
- @NotificationConstants.Action final int selectedAction) {
- final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
- switch (selectedAction) {
- case NotificationConstants.PREVIOUS:
- return getAction(baseActionIcon,
- R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
-
- case NotificationConstants.NEXT:
- return getAction(baseActionIcon,
- R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
-
- case NotificationConstants.REWIND:
- return getAction(baseActionIcon,
- R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
-
- case NotificationConstants.FORWARD:
- return getAction(baseActionIcon,
- R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
-
- case NotificationConstants.SMART_REWIND_PREVIOUS:
- if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
- return getAction(R.drawable.exo_notification_previous,
- R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
- } else {
- return getAction(R.drawable.exo_controls_rewind,
- R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
- }
-
- case NotificationConstants.SMART_FORWARD_NEXT:
- if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
- return getAction(R.drawable.exo_notification_next,
- R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
- } else {
- return getAction(R.drawable.exo_controls_fastforward,
- R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
- }
-
- case NotificationConstants.PLAY_PAUSE_BUFFERING:
- if (player.getCurrentState() == Player.STATE_PREFLIGHT
- || player.getCurrentState() == Player.STATE_BLOCKED
- || player.getCurrentState() == Player.STATE_BUFFERING) {
- // null intent -> show hourglass icon that does nothing when clicked
- return new NotificationCompat.Action(R.drawable.ic_hourglass_top,
- player.getContext().getString(R.string.notification_action_buffering),
- null);
- }
-
- // fallthrough
- case NotificationConstants.PLAY_PAUSE:
- if (player.getCurrentState() == Player.STATE_COMPLETED) {
- return getAction(R.drawable.ic_replay,
- R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
- } else if (player.isPlaying()
- || player.getCurrentState() == Player.STATE_PREFLIGHT
- || player.getCurrentState() == Player.STATE_BLOCKED
- || player.getCurrentState() == Player.STATE_BUFFERING) {
- return getAction(R.drawable.exo_notification_pause,
- R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
- } else {
- return getAction(R.drawable.exo_notification_play,
- R.string.exo_controls_play_description, ACTION_PLAY_PAUSE);
- }
-
- case NotificationConstants.REPEAT:
- if (player.getRepeatMode() == REPEAT_MODE_ALL) {
- return getAction(R.drawable.exo_media_action_repeat_all,
- R.string.exo_controls_repeat_all_description, ACTION_REPEAT);
- } else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
- return getAction(R.drawable.exo_media_action_repeat_one,
- R.string.exo_controls_repeat_one_description, ACTION_REPEAT);
- } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
- return getAction(R.drawable.exo_media_action_repeat_off,
- R.string.exo_controls_repeat_off_description, ACTION_REPEAT);
- }
-
- case NotificationConstants.SHUFFLE:
- if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
- return getAction(R.drawable.exo_controls_shuffle_on,
- R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE);
- } else {
- return getAction(R.drawable.exo_controls_shuffle_off,
- R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE);
- }
-
- case NotificationConstants.CLOSE:
- return getAction(R.drawable.ic_close,
- R.string.close, ACTION_CLOSE);
-
- case NotificationConstants.NOTHING:
- default:
- // do nothing
- return null;
- }
- }
-
- private NotificationCompat.Action getAction(@DrawableRes final int drawable,
- @StringRes final int title,
- final String intentAction) {
- return new NotificationCompat.Action(drawable, player.getContext().getString(title),
- PendingIntentCompat.getBroadcast(player.getContext(), NOTIFICATION_ID,
- new Intent(intentAction), FLAG_UPDATE_CURRENT, false));
+ final PendingIntent intent = PendingIntentCompat.getBroadcast(player.getContext(),
+ NOTIFICATION_ID, new Intent(data.action()), FLAG_UPDATE_CURRENT, false);
+ builder.addAction(new NotificationCompat.Action(data.icon(), data.name(), intent));
}
private Intent getIntentForNotification() {
@@ -364,7 +275,7 @@ public final class NotificationUtil {
final Bitmap thumbnail = player.getThumbnail();
if (thumbnail == null || !showThumbnail) {
// since the builder is reused, make sure the thumbnail is unset if there is not one
- builder.setLargeIcon(null);
+ builder.setLargeIcon((Bitmap) null);
return;
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
new file mode 100644
index 000000000..bc24fbe81
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
@@ -0,0 +1,271 @@
+package org.schabi.newpipe.settings;
+
+import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
+import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceManager;
+
+import org.schabi.newpipe.NewPipeDatabase;
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.error.ErrorUtil;
+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.ZipHelper;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Objects;
+
+public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
+
+ private static final String ZIP_MIME_TYPE = "application/zip";
+
+ private final SimpleDateFormat exportDateFormat =
+ new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
+ private ContentSettingsManager manager;
+ private String importExportDataPathKey;
+ private final ActivityResultLauncher requestImportPathLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+ this::requestImportPathResult);
+ private final ActivityResultLauncher requestExportPathLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+ this::requestExportPathResult);
+
+
+ @Override
+ public void onCreatePreferences(@Nullable final Bundle savedInstanceState,
+ @Nullable final String rootKey) {
+ final File homeDir = ContextCompat.getDataDir(requireContext());
+ Objects.requireNonNull(homeDir);
+ manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir));
+ manager.deleteSettingsFile();
+
+ importExportDataPathKey = getString(R.string.import_export_data_path);
+
+
+ addPreferencesFromResourceRegistry();
+
+ final Preference importDataPreference = requirePreference(R.string.import_data);
+ importDataPreference.setOnPreferenceClickListener((Preference p) -> {
+ NoFileManagerSafeGuard.launchSafe(
+ requestImportPathLauncher,
+ StoredFileHelper.getPicker(requireContext(),
+ ZIP_MIME_TYPE, getImportExportDataUri()),
+ TAG,
+ getContext()
+ );
+
+ return true;
+ });
+
+ final Preference exportDataPreference = requirePreference(R.string.export_data);
+ exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
+ NoFileManagerSafeGuard.launchSafe(
+ requestExportPathLauncher,
+ StoredFileHelper.getNewPicker(requireContext(),
+ "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
+ ZIP_MIME_TYPE, getImportExportDataUri()),
+ TAG,
+ getContext()
+ );
+
+ return true;
+ });
+
+ final Preference resetSettings = findPreference(getString(R.string.reset_settings));
+ // Resets all settings by deleting shared preference and restarting the app
+ // A dialogue will pop up to confirm if user intends to reset all settings
+ assert resetSettings != null;
+ resetSettings.setOnPreferenceClickListener(preference -> {
+ // Show Alert Dialogue
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
+ builder.setMessage(R.string.reset_all_settings);
+ builder.setCancelable(true);
+ builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
+ // Deletes all shared preferences xml files.
+ final SharedPreferences sharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(requireContext());
+ sharedPreferences.edit().clear().apply();
+ // Restarts the app
+ if (getActivity() == null) {
+ return;
+ }
+ NavigationHelper.restartApp(getActivity());
+ });
+ builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> {
+ });
+ final AlertDialog alertDialog = builder.create();
+ alertDialog.show();
+ return true;
+ });
+ }
+
+ private void requestExportPathResult(final ActivityResult result) {
+ assureCorrectAppLanguage(requireContext());
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ // will be saved only on success
+ final Uri lastExportDataUri = result.getData().getData();
+
+ final StoredFileHelper file = new StoredFileHelper(
+ requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
+
+ exportDatabase(file, lastExportDataUri);
+ }
+ }
+
+ private void requestImportPathResult(final ActivityResult result) {
+ assureCorrectAppLanguage(requireContext());
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ // will be saved only on success
+ final Uri lastImportDataUri = result.getData().getData();
+
+ final StoredFileHelper file = new StoredFileHelper(
+ requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
+
+ new androidx.appcompat.app.AlertDialog.Builder(requireActivity())
+ .setMessage(R.string.override_current_data)
+ .setPositiveButton(R.string.ok, (d, id) ->
+ importDatabase(file, lastImportDataUri))
+ .setNegativeButton(R.string.cancel, (d, id) ->
+ d.cancel())
+ .show();
+ }
+ }
+
+ private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
+ try {
+ //checkpoint before export
+ NewPipeDatabase.checkpoint();
+
+ final SharedPreferences preferences = PreferenceManager
+ .getDefaultSharedPreferences(requireContext());
+ manager.exportDatabase(preferences, file);
+
+ saveLastImportExportDataUri(exportDataUri); // save export path only on success
+ Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT)
+ .show();
+ } catch (final Exception e) {
+ ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e);
+ }
+ }
+
+ private void importDatabase(final StoredFileHelper file, final Uri importDataUri) {
+ // check if file is supported
+ if (!ZipHelper.isValidZipFile(file)) {
+ Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
+ .show();
+ return;
+ }
+
+ try {
+ if (!manager.ensureDbDirectoryExists()) {
+ throw new IOException("Could not create databases dir");
+ }
+
+ if (!manager.extractDb(file)) {
+ Toast.makeText(requireContext(), R.string.could_not_import_all_files,
+ Toast.LENGTH_LONG)
+ .show();
+ }
+
+ // if settings file exist, ask if it should be imported.
+ if (manager.extractSettings(file)) {
+ new androidx.appcompat.app.AlertDialog.Builder(requireContext())
+ .setTitle(R.string.import_settings)
+ .setNegativeButton(R.string.cancel, (dialog, which) -> {
+ dialog.dismiss();
+ finishImport(importDataUri);
+ })
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ dialog.dismiss();
+ final Context context = requireContext();
+ final SharedPreferences prefs = PreferenceManager
+ .getDefaultSharedPreferences(context);
+ manager.loadSharedPreferences(prefs);
+ cleanImport(context, prefs);
+ finishImport(importDataUri);
+ })
+ .show();
+ } else {
+ finishImport(importDataUri);
+ }
+ } catch (final Exception e) {
+ ErrorUtil.showUiErrorSnackbar(this, "Importing database", e);
+ }
+ }
+
+ /**
+ * Remove settings that are not supposed to be imported on different devices
+ * and reset them to default values.
+ * @param context the context used for the import
+ * @param prefs the preferences used while running the import
+ */
+ private void cleanImport(@NonNull final Context context,
+ @NonNull final SharedPreferences prefs) {
+ // Check if media tunnelling needs to be disabled automatically,
+ // if it was disabled automatically in the imported preferences.
+ final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key);
+ final String automaticTunnelingKey =
+ context.getString(R.string.disabled_media_tunneling_automatically_key);
+ // R.string.disable_media_tunneling_key should always be true
+ // if R.string.disabled_media_tunneling_automatically_key equals 1,
+ // but we double check here just to be sure and to avoid regressions
+ // caused by possible later modification of the media tunneling functionality.
+ // R.string.disabled_media_tunneling_automatically_key == 0:
+ // automatic value overridden by user in settings
+ // R.string.disabled_media_tunneling_automatically_key == -1: not set
+ final boolean wasMediaTunnelingDisabledAutomatically =
+ prefs.getInt(automaticTunnelingKey, -1) == 1
+ && prefs.getBoolean(tunnelingKey, false);
+ if (wasMediaTunnelingDisabledAutomatically) {
+ prefs.edit()
+ .putInt(automaticTunnelingKey, -1)
+ .putBoolean(tunnelingKey, false)
+ .apply();
+ NewPipeSettings.setMediaTunneling(context);
+ }
+ }
+
+ /**
+ * Save import path and restart system.
+ *
+ * @param importDataUri The import path to save
+ */
+ private void finishImport(final Uri importDataUri) {
+ // save import path only on success
+ saveLastImportExportDataUri(importDataUri);
+ // restart app to properly load db
+ NavigationHelper.restartApp(requireActivity());
+ }
+
+ private Uri getImportExportDataUri() {
+ final String path = defaultPreferences.getString(importExportDataPathKey, null);
+ return isBlank(path) ? null : Uri.parse(path);
+ }
+
+ private void saveLastImportExportDataUri(final Uri importExportDataUri) {
+ final SharedPreferences.Editor editor = defaultPreferences.edit()
+ .putString(importExportDataPathKey, importExportDataUri.toString());
+ editor.apply();
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
index c7d107acb..ec2bed67a 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
@@ -1,106 +1,36 @@
package org.schabi.newpipe.settings;
-import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
-import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
-
-import android.app.Activity;
import android.content.Context;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
-import androidx.activity.result.ActivityResult;
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.core.content.ContextCompat;
import androidx.preference.Preference;
-import androidx.preference.PreferenceManager;
import org.schabi.newpipe.DownloaderImpl;
-import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
-import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.localization.Localization;
-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.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
-import org.schabi.newpipe.util.ZipHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
-import java.io.File;
import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.Locale;
-import java.util.Objects;
public class ContentSettingsFragment extends BasePreferenceFragment {
- private static final String ZIP_MIME_TYPE = "application/zip";
-
- private final SimpleDateFormat exportDateFormat =
- new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
-
- private ContentSettingsManager manager;
-
- private String importExportDataPathKey;
private String youtubeRestrictedModeEnabledKey;
private Localization initialSelectedLocalization;
private ContentCountry initialSelectedContentCountry;
private String initialLanguage;
- private final ActivityResultLauncher requestImportPathLauncher =
- registerForActivityResult(new StartActivityForResult(), this::requestImportPathResult);
- private final ActivityResultLauncher requestExportPathLauncher =
- registerForActivityResult(new StartActivityForResult(), this::requestExportPathResult);
@Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
- final File homeDir = ContextCompat.getDataDir(requireContext());
- Objects.requireNonNull(homeDir);
- manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir));
- manager.deleteSettingsFile();
-
- importExportDataPathKey = getString(R.string.import_export_data_path);
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
addPreferencesFromResourceRegistry();
- final Preference importDataPreference = requirePreference(R.string.import_data);
- importDataPreference.setOnPreferenceClickListener((Preference p) -> {
- NoFileManagerSafeGuard.launchSafe(
- requestImportPathLauncher,
- StoredFileHelper.getPicker(requireContext(),
- ZIP_MIME_TYPE, getImportExportDataUri()),
- TAG,
- getContext()
- );
-
- return true;
- });
-
- final Preference exportDataPreference = requirePreference(R.string.export_data);
- exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
- NoFileManagerSafeGuard.launchSafe(
- requestExportPathLauncher,
- StoredFileHelper.getNewPicker(requireContext(),
- "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
- ZIP_MIME_TYPE, getImportExportDataUri()),
- TAG,
- getContext()
- );
-
- return true;
- });
-
initialSelectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
@@ -158,151 +88,4 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
NewPipe.setupLocalization(selectedLocalization, selectedContentCountry);
}
}
-
- private void requestExportPathResult(final ActivityResult result) {
- assureCorrectAppLanguage(getContext());
- if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
- // will be saved only on success
- final Uri lastExportDataUri = result.getData().getData();
-
- final StoredFileHelper file =
- new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
-
- exportDatabase(file, lastExportDataUri);
- }
- }
-
- private void requestImportPathResult(final ActivityResult result) {
- assureCorrectAppLanguage(getContext());
- if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
- // will be saved only on success
- final Uri lastImportDataUri = result.getData().getData();
-
- final StoredFileHelper file =
- new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
-
- new AlertDialog.Builder(requireActivity())
- .setMessage(R.string.override_current_data)
- .setPositiveButton(R.string.ok, (d, id) ->
- importDatabase(file, lastImportDataUri))
- .setNegativeButton(R.string.cancel, (d, id) ->
- d.cancel())
- .show();
- }
- }
-
- private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
- try {
- //checkpoint before export
- NewPipeDatabase.checkpoint();
-
- final SharedPreferences preferences = PreferenceManager
- .getDefaultSharedPreferences(requireContext());
- manager.exportDatabase(preferences, file);
-
- saveLastImportExportDataUri(exportDataUri); // save export path only on success
- Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show();
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e);
- }
- }
-
- private void importDatabase(final StoredFileHelper file, final Uri importDataUri) {
- // check if file is supported
- if (!ZipHelper.isValidZipFile(file)) {
- Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
- .show();
- return;
- }
-
- try {
- if (!manager.ensureDbDirectoryExists()) {
- throw new IOException("Could not create databases dir");
- }
-
- if (!manager.extractDb(file)) {
- Toast.makeText(getContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG)
- .show();
- }
-
- // if settings file exist, ask if it should be imported.
- if (manager.extractSettings(file)) {
- new AlertDialog.Builder(requireContext())
- .setTitle(R.string.import_settings)
- .setNegativeButton(R.string.cancel, (dialog, which) -> {
- dialog.dismiss();
- finishImport(importDataUri);
- })
- .setPositiveButton(R.string.ok, (dialog, which) -> {
- dialog.dismiss();
- final Context context = requireContext();
- final SharedPreferences prefs = PreferenceManager
- .getDefaultSharedPreferences(context);
- manager.loadSharedPreferences(prefs);
- cleanImport(context, prefs);
- finishImport(importDataUri);
- })
- .show();
- } else {
- finishImport(importDataUri);
- }
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Importing database", e);
- }
- }
-
- /**
- * Remove settings that are not supposed to be imported on different devices
- * and reset them to default values.
- * @param context the context used for the import
- * @param prefs the preferences used while running the import
- */
- private void cleanImport(@NonNull final Context context,
- @NonNull final SharedPreferences prefs) {
- // Check if media tunnelling needs to be disabled automatically,
- // if it was disabled automatically in the imported preferences.
- final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key);
- final String automaticTunnelingKey =
- context.getString(R.string.disabled_media_tunneling_automatically_key);
- // R.string.disable_media_tunneling_key should always be true
- // if R.string.disabled_media_tunneling_automatically_key equals 1,
- // but we double check here just to be sure and to avoid regressions
- // caused by possible later modification of the media tunneling functionality.
- // R.string.disabled_media_tunneling_automatically_key == 0:
- // automatic value overridden by user in settings
- // R.string.disabled_media_tunneling_automatically_key == -1: not set
- final boolean wasMediaTunnelingDisabledAutomatically =
- prefs.getInt(automaticTunnelingKey, -1) == 1
- && prefs.getBoolean(tunnelingKey, false);
- if (wasMediaTunnelingDisabledAutomatically) {
- prefs.edit()
- .putInt(automaticTunnelingKey, -1)
- .putBoolean(tunnelingKey, false)
- .apply();
- NewPipeSettings.setMediaTunneling(context);
- }
- }
-
- /**
- * Save import path and restart system.
- *
- * @param importDataUri The import path to save
- */
- private void finishImport(final Uri importDataUri) {
- // save import path only on success
- saveLastImportExportDataUri(importDataUri);
- // restart app to properly load db
- NavigationHelper.restartApp(requireActivity());
- }
-
- private Uri getImportExportDataUri() {
- final String path = defaultPreferences.getString(importExportDataPathKey, null);
- return isBlank(path) ? null : Uri.parse(path);
- }
-
- private void saveLastImportExportDataUri(final Uri importExportDataUri) {
- final SharedPreferences.Editor editor = defaultPreferences.edit()
- .putString(importExportDataPathKey, importExportDataUri.toString());
- editor.apply();
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
index 3776d78f6..32e33d55b 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
@@ -23,7 +23,7 @@ public class MainSettingsFragment extends BasePreferenceFragment {
setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called
// Check if the app is updatable
- if (!ReleaseVersionUtil.isReleaseApk()) {
+ if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
getPreferenceScreen().removePreference(
findPreference(getString(R.string.update_pref_screen_key)));
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
index b85b95eb0..421440ea7 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -11,6 +11,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.preference.PreferenceManager;
+import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.util.DeviceUtils;
@@ -44,14 +45,8 @@ public final class NewPipeSettings {
private NewPipeSettings() { }
public static void initSettings(final Context context) {
- // check if the last used preference version is set
- // to determine whether this is the first app run
- final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(context)
- .getInt(context.getString(R.string.last_used_preferences_version), -1);
- final boolean isFirstRun = lastUsedPrefVersion == -1;
-
// first run migrations, then setDefaultValues, since the latter requires the correct types
- SettingMigrations.runMigrationsIfNeeded(context, isFirstRun);
+ SettingMigrations.runMigrationsIfNeeded(context);
// readAgain is true so that if new settings are added their default value is set
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
@@ -63,11 +58,12 @@ public final class NewPipeSettings {
PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.update_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true);
+ PreferenceManager.setDefaultValues(context, R.xml.backup_restore_settings, true);
saveDefaultVideoDownloadDirectory(context);
saveDefaultAudioDownloadDirectory(context);
- disableMediaTunnelingIfNecessary(context, isFirstRun);
+ disableMediaTunnelingIfNecessary(context);
}
static void saveDefaultVideoDownloadDirectory(final Context context) {
@@ -145,8 +141,7 @@ public final class NewPipeSettings {
R.string.show_remote_search_suggestions_key);
}
- private static void disableMediaTunnelingIfNecessary(@NonNull final Context context,
- final boolean isFirstRun) {
+ private static void disableMediaTunnelingIfNecessary(@NonNull final Context context) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
final String disabledTunnelingKey = context.getString(R.string.disable_media_tunneling_key);
final String disabledTunnelingAutomaticallyKey =
@@ -161,7 +156,7 @@ public final class NewPipeSettings {
prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0
&& !prefs.getBoolean(disabledTunnelingKey, false);
- if (Boolean.TRUE.equals(isFirstRun)
+ if (App.getApp().isFirstRun()
|| (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) {
setMediaTunneling(context);
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
index 147d20a36..36abef9e5 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.settings;
+import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
+
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -31,7 +33,6 @@ import java.util.List;
import java.util.Vector;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.disposables.Disposable;
public class SelectPlaylistFragment extends DialogFragment {
@@ -90,8 +91,7 @@ public class SelectPlaylistFragment extends DialogFragment {
final LocalPlaylistManager localPlaylistManager = new LocalPlaylistManager(database);
final RemotePlaylistManager remotePlaylistManager = new RemotePlaylistManager(database);
- disposable = Flowable.combineLatest(localPlaylistManager.getPlaylists(),
- remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
+ disposable = getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::displayPlaylists, this::onError);
}
@@ -118,7 +118,7 @@ public class SelectPlaylistFragment extends DialogFragment {
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
- onSelectedListener.onLocalPlaylistSelected(entry.uid, entry.name);
+ onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
index b7bafde75..d731f2f5e 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
@@ -7,6 +7,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
+import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
@@ -163,15 +164,14 @@ public final class SettingMigrations {
private static final int VERSION = 6;
- public static void runMigrationsIfNeeded(@NonNull final Context context,
- final boolean isFirstRun) {
+ public static void runMigrationsIfNeeded(@NonNull final Context context) {
// setup migrations and check if there is something to do
sp = PreferenceManager.getDefaultSharedPreferences(context);
final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version);
final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0);
// no migration to run, already up to date
- if (isFirstRun) {
+ if (App.getApp().isFirstRun()) {
sp.edit().putInt(lastPrefVersionKey, VERSION).apply();
return;
} else if (lastPrefVersion == VERSION) {
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
index 3ee6668bf..529e53442 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
@@ -266,7 +266,7 @@ public class SettingsActivity extends AppCompatActivity implements
*/
private void ensureSearchRepresentsApplicationState() {
// Check if the update settings are available
- if (!ReleaseVersionUtil.isReleaseApk()) {
+ if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
SettingsResourceRegistry.getInstance()
.getEntryByPreferencesResId(R.xml.update_settings)
.setSearchable(false);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
index b3d0741bb..06e0a7c1e 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
@@ -41,6 +41,7 @@ public final class SettingsResourceRegistry {
add(UpdateSettingsFragment.class, R.xml.update_settings);
add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings);
add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings);
+ add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings);
}
private SettingRegistryEntry add(
diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
index d1a379e66..b8d0aa556 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
@@ -1,9 +1,12 @@
package org.schabi.newpipe.settings;
+import android.app.AlertDialog;
+import android.content.Context;
import android.os.Bundle;
import android.widget.Toast;
import androidx.preference.Preference;
+import androidx.preference.PreferenceManager;
import org.schabi.newpipe.NewVersionWorker;
import org.schabi.newpipe.R;
@@ -36,4 +39,38 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
findPreference(getString(R.string.manual_update_key))
.setOnPreferenceClickListener(manualUpdateClick);
}
+
+ public static void askForConsentToUpdateChecks(final Context context) {
+ new AlertDialog.Builder(context)
+ .setTitle(context.getString(R.string.check_for_updates))
+ .setMessage(context.getString(R.string.auto_update_check_description))
+ .setPositiveButton(context.getString(R.string.yes), (d, w) -> {
+ d.dismiss();
+ setAutoUpdateCheckEnabled(context, true);
+ })
+ .setNegativeButton(R.string.no, (d, w) -> {
+ d.dismiss();
+ // set explicitly to false, since the default is true on previous versions
+ setAutoUpdateCheckEnabled(context, false);
+ })
+ .show();
+ }
+
+ private static void setAutoUpdateCheckEnabled(final Context context, final boolean enabled) {
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(context.getString(R.string.update_app_key), enabled)
+ .putBoolean(context.getString(R.string.update_check_consent_key), true)
+ .apply();
+ }
+
+ /**
+ * Whether the user was asked for consent to automatically check for app updates.
+ * @param context
+ * @return true if the user was asked for consent, false otherwise
+ */
+ public static boolean wasUserAskedForConsent(final Context context) {
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.update_check_consent_key), false);
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java
index 3e92f297e..7dfddef20 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java
@@ -5,35 +5,22 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
-import android.content.res.ColorStateList;
+import android.os.Build;
import android.util.AttributeSet;
-import android.view.LayoutInflater;
import android.view.View;
-import android.view.ViewGroup;
import android.widget.CheckBox;
-import android.widget.ImageView;
-import android.widget.RadioButton;
-import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.core.widget.TextViewCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
-import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.player.notification.NotificationConstants;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.views.FocusOverlayView;
+import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
@@ -45,8 +32,9 @@ public class NotificationActionsPreference extends Preference {
}
- @Nullable private NotificationSlot[] notificationSlots = null;
- @Nullable private List compactSlots = null;
+ private NotificationSlot[] notificationSlots;
+ private List compactSlots;
+
////////////////////////////////////////////////////////////////////////////
// Lifecycle
@@ -56,6 +44,11 @@ public class NotificationActionsPreference extends Preference {
public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ((TextView) holder.itemView.findViewById(R.id.summary))
+ .setText(R.string.notification_actions_summary_android13);
+ }
+
holder.itemView.setClickable(false);
setupActions(holder.itemView);
}
@@ -75,13 +68,29 @@ public class NotificationActionsPreference extends Preference {
////////////////////////////////////////////////////////////////////////////
private void setupActions(@NonNull final View view) {
- compactSlots = NotificationConstants.getCompactSlotsFromPreferences(getContext(),
- getSharedPreferences(), 5);
+ compactSlots = new ArrayList<>(NotificationConstants.getCompactSlotsFromPreferences(
+ getContext(), getSharedPreferences()));
notificationSlots = IntStream.range(0, 5)
- .mapToObj(i -> new NotificationSlot(i, view))
+ .mapToObj(i -> new NotificationSlot(getContext(), getSharedPreferences(), i, view,
+ compactSlots.contains(i), this::onToggleCompactSlot))
.toArray(NotificationSlot[]::new);
}
+ private void onToggleCompactSlot(final int i, final CheckBox checkBox) {
+ if (checkBox.isChecked()) {
+ compactSlots.remove((Integer) i);
+ } else if (compactSlots.size() < 3) {
+ compactSlots.add(i);
+ } else {
+ Toast.makeText(getContext(),
+ R.string.notification_actions_at_most_three,
+ Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ checkBox.toggle();
+ }
+
////////////////////////////////////////////////////////////////////////////
// Saving
@@ -99,143 +108,10 @@ public class NotificationActionsPreference extends Preference {
for (int i = 0; i < 5; i++) {
editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
- notificationSlots[i].selectedAction);
+ notificationSlots[i].getSelectedAction());
}
editor.apply();
}
}
-
-
- ////////////////////////////////////////////////////////////////////////////
- // Notification action
- ////////////////////////////////////////////////////////////////////////////
-
- private static final int[] SLOT_ITEMS = {
- R.id.notificationAction0,
- R.id.notificationAction1,
- R.id.notificationAction2,
- R.id.notificationAction3,
- R.id.notificationAction4,
- };
-
- private static final int[] SLOT_TITLES = {
- R.string.notification_action_0_title,
- R.string.notification_action_1_title,
- R.string.notification_action_2_title,
- R.string.notification_action_3_title,
- R.string.notification_action_4_title,
- };
-
- private class NotificationSlot {
-
- final int i;
- @NotificationConstants.Action int selectedAction;
-
- ImageView icon;
- TextView summary;
-
- NotificationSlot(final int actionIndex, final View parentView) {
- this.i = actionIndex;
-
- final View view = parentView.findViewById(SLOT_ITEMS[i]);
- setupSelectedAction(view);
- setupTitle(view);
- setupCheckbox(view);
- }
-
- void setupTitle(final View view) {
- ((TextView) view.findViewById(R.id.notificationActionTitle))
- .setText(SLOT_TITLES[i]);
- view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
- v -> openActionChooserDialog());
- }
-
- void setupCheckbox(final View view) {
- final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
- compactSlotCheckBox.setChecked(compactSlots.contains(i));
- view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
- v -> {
- if (compactSlotCheckBox.isChecked()) {
- compactSlots.remove((Integer) i);
- } else if (compactSlots.size() < 3) {
- compactSlots.add(i);
- } else {
- Toast.makeText(getContext(),
- R.string.notification_actions_at_most_three,
- Toast.LENGTH_SHORT).show();
- return;
- }
-
- compactSlotCheckBox.toggle();
- });
- }
-
- void setupSelectedAction(final View view) {
- icon = view.findViewById(R.id.notificationActionIcon);
- summary = view.findViewById(R.id.notificationActionSummary);
- selectedAction = getSharedPreferences().getInt(
- getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
- NotificationConstants.SLOT_DEFAULTS[i]);
- updateInfo();
- }
-
- void updateInfo() {
- if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
- icon.setImageDrawable(null);
- } else {
- icon.setImageDrawable(AppCompatResources.getDrawable(getContext(),
- NotificationConstants.ACTION_ICONS[selectedAction]));
- }
-
- summary.setText(NotificationConstants.getActionName(getContext(), selectedAction));
- }
-
- void openActionChooserDialog() {
- final LayoutInflater inflater = LayoutInflater.from(getContext());
- final SingleChoiceDialogViewBinding binding =
- SingleChoiceDialogViewBinding.inflate(inflater);
-
- final AlertDialog alertDialog = new AlertDialog.Builder(getContext())
- .setTitle(SLOT_TITLES[i])
- .setView(binding.getRoot())
- .setCancelable(true)
- .create();
-
- final View.OnClickListener radioButtonsClickListener = v -> {
- selectedAction = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][v.getId()];
- updateInfo();
- alertDialog.dismiss();
- };
-
- for (int id = 0; id < NotificationConstants.SLOT_ALLOWED_ACTIONS[i].length; ++id) {
- final int action = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][id];
- final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
- .getRoot();
-
- // if present set action icon with correct color
- final int iconId = NotificationConstants.ACTION_ICONS[action];
- if (iconId != 0) {
- radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
-
- final var color = ColorStateList.valueOf(ThemeHelper
- .resolveColorFromAttr(getContext(), android.R.attr.textColorPrimary));
- TextViewCompat.setCompoundDrawableTintList(radioButton, color);
- }
-
- radioButton.setText(NotificationConstants.getActionName(getContext(), action));
- radioButton.setChecked(action == selectedAction);
- radioButton.setId(id);
- radioButton.setLayoutParams(new RadioGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
- radioButton.setOnClickListener(radioButtonsClickListener);
- binding.list.addView(radioButton);
- }
- alertDialog.show();
-
- if (DeviceUtils.isTv(getContext())) {
- FocusOverlayView.setupFocusObserver(alertDialog);
- }
- }
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java
new file mode 100644
index 000000000..981ba3e75
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java
@@ -0,0 +1,172 @@
+package org.schabi.newpipe.settings.custom;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.ColorStateList;
+import android.os.Build;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.widget.TextViewCompat;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
+import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
+import org.schabi.newpipe.player.notification.NotificationConstants;
+import org.schabi.newpipe.util.DeviceUtils;
+import org.schabi.newpipe.util.ThemeHelper;
+import org.schabi.newpipe.views.FocusOverlayView;
+
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+class NotificationSlot {
+
+ private static final int[] SLOT_ITEMS = {
+ R.id.notificationAction0,
+ R.id.notificationAction1,
+ R.id.notificationAction2,
+ R.id.notificationAction3,
+ R.id.notificationAction4,
+ };
+
+ private static final int[] SLOT_TITLES = {
+ R.string.notification_action_0_title,
+ R.string.notification_action_1_title,
+ R.string.notification_action_2_title,
+ R.string.notification_action_3_title,
+ R.string.notification_action_4_title,
+ };
+
+ private final int i;
+ private @NotificationConstants.Action int selectedAction;
+ private final Context context;
+ private final BiConsumer onToggleCompactSlot;
+
+ private ImageView icon;
+ private TextView summary;
+
+ NotificationSlot(final Context context,
+ final SharedPreferences prefs,
+ final int actionIndex,
+ final View parentView,
+ final boolean isCompactSlotChecked,
+ final BiConsumer onToggleCompactSlot) {
+ this.context = context;
+ this.i = actionIndex;
+ this.onToggleCompactSlot = onToggleCompactSlot;
+
+ selectedAction = Objects.requireNonNull(prefs).getInt(
+ context.getString(NotificationConstants.SLOT_PREF_KEYS[i]),
+ NotificationConstants.SLOT_DEFAULTS[i]);
+ final View view = parentView.findViewById(SLOT_ITEMS[i]);
+
+ // only show the last two notification slots on Android 13+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || i >= 3) {
+ setupSelectedAction(view);
+ setupTitle(view);
+ setupCheckbox(view, isCompactSlotChecked);
+ } else {
+ view.setVisibility(View.GONE);
+ }
+ }
+
+ void setupTitle(final View view) {
+ ((TextView) view.findViewById(R.id.notificationActionTitle))
+ .setText(SLOT_TITLES[i]);
+ view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
+ v -> openActionChooserDialog());
+ }
+
+ void setupCheckbox(final View view, final boolean isCompactSlotChecked) {
+ final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ // there are no compact slots to customize on Android 13+
+ compactSlotCheckBox.setVisibility(View.GONE);
+ view.findViewById(R.id.notificationActionCheckBoxClickableArea)
+ .setVisibility(View.GONE);
+ return;
+ }
+
+ compactSlotCheckBox.setChecked(isCompactSlotChecked);
+ view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
+ v -> onToggleCompactSlot.accept(i, compactSlotCheckBox));
+ }
+
+ void setupSelectedAction(final View view) {
+ icon = view.findViewById(R.id.notificationActionIcon);
+ summary = view.findViewById(R.id.notificationActionSummary);
+ updateInfo();
+ }
+
+ void updateInfo() {
+ if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
+ icon.setImageDrawable(null);
+ } else {
+ icon.setImageDrawable(AppCompatResources.getDrawable(context,
+ NotificationConstants.ACTION_ICONS[selectedAction]));
+ }
+
+ summary.setText(NotificationConstants.getActionName(context, selectedAction));
+ }
+
+ void openActionChooserDialog() {
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ final SingleChoiceDialogViewBinding binding =
+ SingleChoiceDialogViewBinding.inflate(inflater);
+
+ final AlertDialog alertDialog = new AlertDialog.Builder(context)
+ .setTitle(SLOT_TITLES[i])
+ .setView(binding.getRoot())
+ .setCancelable(true)
+ .create();
+
+ final View.OnClickListener radioButtonsClickListener = v -> {
+ selectedAction = NotificationConstants.ALL_ACTIONS[v.getId()];
+ updateInfo();
+ alertDialog.dismiss();
+ };
+
+ for (int id = 0; id < NotificationConstants.ALL_ACTIONS.length; ++id) {
+ final int action = NotificationConstants.ALL_ACTIONS[id];
+ final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
+ .getRoot();
+
+ // if present set action icon with correct color
+ final int iconId = NotificationConstants.ACTION_ICONS[action];
+ if (iconId != 0) {
+ radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
+
+ final var color = ColorStateList.valueOf(ThemeHelper
+ .resolveColorFromAttr(context, android.R.attr.textColorPrimary));
+ TextViewCompat.setCompoundDrawableTintList(radioButton, color);
+ }
+
+ radioButton.setText(NotificationConstants.getActionName(context, action));
+ radioButton.setChecked(action == selectedAction);
+ radioButton.setId(id);
+ radioButton.setLayoutParams(new RadioGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ radioButton.setOnClickListener(radioButtonsClickListener);
+ binding.list.addView(radioButton);
+ }
+ alertDialog.show();
+
+ if (DeviceUtils.isTv(context)) {
+ FocusOverlayView.setupFocusObserver(alertDialog);
+ }
+ }
+
+ @NotificationConstants.Action
+ public int getSelectedAction() {
+ return selectedAction;
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
index 74fc74c76..bb47a4b91 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
@@ -1,11 +1,18 @@
package org.schabi.newpipe.streams.io;
+import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
+import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
+import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
+
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
+import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
+import android.system.Os;
+import android.system.StructStatVfs;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -15,6 +22,7 @@ import androidx.documentfile.provider.DocumentFile;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.FilePickerActivityHelper;
+import java.io.FileDescriptor;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
@@ -26,10 +34,6 @@ import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
-import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
-import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
-
public class StoredDirectoryHelper {
private static final String TAG = StoredDirectoryHelper.class.getSimpleName();
public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
@@ -38,6 +42,10 @@ public class StoredDirectoryHelper {
private Path ioTree;
private DocumentFile docTree;
+ /**
+ * Context is `null` for non-SAF files, i.e. files that use `ioTree`.
+ */
+ @Nullable
private Context context;
private final String tag;
@@ -168,6 +176,46 @@ public class StoredDirectoryHelper {
return docTree == null;
}
+ /**
+ * Get free memory of the storage partition this file belongs to (root of the directory).
+ * See StackOverflow and
+ *
+ * {@code statvfs()} and {@code fstatvfs()} docs
+ *
+ * @return amount of free memory in the volume of current directory (bytes), or {@link
+ * Long#MAX_VALUE} if an error occurred
+ */
+ public long getFreeStorageSpace() {
+ try {
+ final StructStatVfs stat;
+
+ if (ioTree != null) {
+ // non-SAF file, use statvfs with the path directly (also, `context` would be null
+ // for non-SAF files, so we wouldn't be able to call `getContentResolver` anyway)
+ stat = Os.statvfs(ioTree.toString());
+
+ } else {
+ // SAF file, we can't get a path directly, so obtain a file descriptor first
+ // and then use fstatvfs with the file descriptor
+ try (ParcelFileDescriptor parcelFileDescriptor =
+ context.getContentResolver().openFileDescriptor(getUri(), "r")) {
+ if (parcelFileDescriptor == null) {
+ return Long.MAX_VALUE;
+ }
+ final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
+ stat = Os.fstatvfs(fileDescriptor);
+ }
+ }
+
+ // this is the same formula used inside the FsStat class
+ return stat.f_bavail * stat.f_frsize;
+ } catch (final Throwable e) {
+ // ignore any error
+ Log.e(TAG, "Could not get free storage space", e);
+ return Long.MAX_VALUE;
+ }
+ }
+
/**
* Only using Java I/O. Creates the directory named by this abstract pathname, including any
* necessary but nonexistent parent directories.
diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
index 07d0f516d..066d5f570 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
@@ -27,6 +27,7 @@ import android.util.Log;
import android.view.View;
import android.widget.TextView;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import androidx.preference.PreferenceManager;
@@ -113,14 +114,14 @@ public final class ExtractorHelper {
public static Single getStreamInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.STREAM,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.STREAM,
Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url)));
}
public static Single getChannelInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.CHANNEL,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.CHANNEL,
Single.fromCallable(() ->
ChannelInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -130,7 +131,7 @@ public final class ExtractorHelper {
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId,
- listLinkHandler.getUrl(), InfoItem.InfoType.CHANNEL,
+ listLinkHandler.getUrl(), InfoCache.Type.CHANNEL_TAB,
Single.fromCallable(() ->
ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler)));
}
@@ -145,10 +146,11 @@ public final class ExtractorHelper {
listLinkHandler, nextPage));
}
- public static Single getCommentsInfo(final int serviceId, final String url,
+ public static Single getCommentsInfo(final int serviceId,
+ final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.COMMENT,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS,
Single.fromCallable(() ->
CommentsInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -162,11 +164,20 @@ public final class ExtractorHelper {
CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage));
}
+ public static Single> getMoreCommentItems(
+ final int serviceId,
+ final String url,
+ final Page nextPage) {
+ checkServiceId(serviceId);
+ return Single.fromCallable(() ->
+ CommentsInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
+ }
+
public static Single getPlaylistInfo(final int serviceId,
final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.PLAYLIST,
Single.fromCallable(() ->
PlaylistInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -179,9 +190,10 @@ public final class ExtractorHelper {
PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
}
- public static Single getKioskInfo(final int serviceId, final String url,
+ public static Single getKioskInfo(final int serviceId,
+ final String url,
final boolean forceLoad) {
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK,
Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -193,7 +205,7 @@ public final class ExtractorHelper {
}
/*//////////////////////////////////////////////////////////////////////////
- // Utils
+ // Cache
//////////////////////////////////////////////////////////////////////////*/
/**
@@ -205,24 +217,25 @@ public final class ExtractorHelper {
* @param forceLoad whether to force loading from the network instead of from the cache
* @param serviceId the service to load from
* @param url the URL to load
- * @param infoType the {@link InfoItem.InfoType} of the item
+ * @param cacheType the {@link InfoCache.Type} of the item
* @param loadFromNetwork the {@link Single} to load the item from the network
* @return a {@link Single} that loads the item
*/
private static Single checkCache(final boolean forceLoad,
- final int serviceId, final String url,
- final InfoItem.InfoType infoType,
- final Single loadFromNetwork) {
+ final int serviceId,
+ @NonNull final String url,
+ @NonNull final InfoCache.Type cacheType,
+ @NonNull final Single loadFromNetwork) {
checkServiceId(serviceId);
final Single actualLoadFromNetwork = loadFromNetwork
- .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, infoType));
+ .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, cacheType));
final Single load;
if (forceLoad) {
- CACHE.removeInfo(serviceId, url, infoType);
+ CACHE.removeInfo(serviceId, url, cacheType);
load = actualLoadFromNetwork;
} else {
- load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType),
+ load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, cacheType),
actualLoadFromNetwork.toMaybe())
.firstElement() // Take the first valid
.toSingle();
@@ -237,15 +250,17 @@ public final class ExtractorHelper {
* @param the item type's class that extends {@link Info}
* @param serviceId the service to load from
* @param url the URL to load
- * @param infoType the {@link InfoItem.InfoType} of the item
+ * @param cacheType the {@link InfoCache.Type} of the item
* @return a {@link Single} that loads the item
*/
- private static Maybe loadFromCache(final int serviceId, final String url,
- final InfoItem.InfoType infoType) {
+ private static Maybe loadFromCache(
+ final int serviceId,
+ @NonNull final String url,
+ @NonNull final InfoCache.Type cacheType) {
checkServiceId(serviceId);
return Maybe.defer(() -> {
//noinspection unchecked
- final I info = (I) CACHE.getFromKey(serviceId, url, infoType);
+ final I info = (I) CACHE.getFromKey(serviceId, url, cacheType);
if (MainActivity.DEBUG) {
Log.d(TAG, "loadFromCache() called, info > " + info);
}
@@ -259,11 +274,17 @@ public final class ExtractorHelper {
});
}
- public static boolean isCached(final int serviceId, final String url,
- final InfoItem.InfoType infoType) {
- return null != loadFromCache(serviceId, url, infoType).blockingGet();
+ public static boolean isCached(final int serviceId,
+ @NonNull final String url,
+ @NonNull final InfoCache.Type cacheType) {
+ return null != loadFromCache(serviceId, url, cacheType).blockingGet();
}
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Utils
+ //////////////////////////////////////////////////////////////////////////*/
+
/**
* Formats the text contained in the meta info list as HTML and puts it into the text view,
* while also making the separator visible. If the list is null or empty, or the user chose not
diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
index a07f05828..b9c91f8a5 100644
--- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
+++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
@@ -27,7 +27,6 @@ import androidx.collection.LruCache;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.Info;
-import org.schabi.newpipe.extractor.InfoItem;
import java.util.Map;
@@ -48,14 +47,27 @@ public final class InfoCache {
// no instance
}
+ /**
+ * Identifies the type of {@link Info} to put into the cache.
+ */
+ public enum Type {
+ STREAM,
+ CHANNEL,
+ CHANNEL_TAB,
+ COMMENTS,
+ PLAYLIST,
+ KIOSK,
+ }
+
public static InfoCache getInstance() {
return INSTANCE;
}
@NonNull
- private static String keyOf(final int serviceId, @NonNull final String url,
- @NonNull final InfoItem.InfoType infoType) {
- return serviceId + url + infoType.toString();
+ private static String keyOf(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Type cacheType) {
+ return serviceId + ":" + cacheType.ordinal() + ":" + url;
}
private static void removeStaleCache() {
@@ -83,19 +95,22 @@ public final class InfoCache {
}
@Nullable
- public Info getFromKey(final int serviceId, @NonNull final String url,
- @NonNull final InfoItem.InfoType infoType) {
+ public Info getFromKey(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "getFromKey() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
- return getInfo(keyOf(serviceId, url, infoType));
+ return getInfo(keyOf(serviceId, url, cacheType));
}
}
- public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info,
- @NonNull final InfoItem.InfoType infoType) {
+ public void putInfo(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Info info,
+ @NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "putInfo() called with: info = [" + info + "]");
}
@@ -103,18 +118,19 @@ public final class InfoCache {
final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId());
synchronized (LRU_CACHE) {
final CacheData data = new CacheData(info, expirationMillis);
- LRU_CACHE.put(keyOf(serviceId, url, infoType), data);
+ LRU_CACHE.put(keyOf(serviceId, url, cacheType), data);
}
}
- public void removeInfo(final int serviceId, @NonNull final String url,
- @NonNull final InfoItem.InfoType infoType) {
+ public void removeInfo(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "removeInfo() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
- LRU_CACHE.remove(keyOf(serviceId, url, infoType));
+ LRU_CACHE.remove(keyOf(serviceId, url, cacheType));
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
index 71071d997..f1904565d 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
@@ -643,6 +643,7 @@ public final class ListHelper {
context.getString(R.string.best_resolution_key), defaultFormat, videoStreams);
}
+ @Nullable
private static MediaFormat getDefaultFormat(@NonNull final Context context,
@StringRes final int defaultFormatKey,
@StringRes final int defaultFormatValueKey) {
@@ -651,18 +652,14 @@ public final class ListHelper {
final String defaultFormat = context.getString(defaultFormatValueKey);
final String defaultFormatString = preferences.getString(
- context.getString(defaultFormatKey), defaultFormat);
+ context.getString(defaultFormatKey),
+ defaultFormat
+ );
- MediaFormat defaultMediaFormat = getMediaFormatFromKey(context, defaultFormatString);
- if (defaultMediaFormat == null) {
- preferences.edit().putString(context.getString(defaultFormatKey), defaultFormat)
- .apply();
- defaultMediaFormat = getMediaFormatFromKey(context, defaultFormat);
- }
-
- return defaultMediaFormat;
+ return getMediaFormatFromKey(context, defaultFormatString);
}
+ @Nullable
private static MediaFormat getMediaFormatFromKey(@NonNull final Context context,
@NonNull final String formatKey) {
MediaFormat format = null;
@@ -877,6 +874,7 @@ public final class ListHelper {
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))
- .thenComparing(AudioStream::getAudioTrackType);
+ .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast(
+ Comparator.naturalOrder()));
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java
index c4034252d..bc113e8f8 100644
--- a/app/src/main/java/org/schabi/newpipe/util/Localization.java
+++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
+import static org.schabi.newpipe.MainActivity.DEBUG;
+
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
@@ -22,6 +24,7 @@ import org.ocpsoft.prettytime.units.Decade;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.localization.ContentCountry;
+import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.AudioTrackType;
@@ -82,7 +85,7 @@ public final class Localization {
.fromLocale(getPreferredLocale(context));
}
- public static ContentCountry getPreferredContentCountry(final Context context) {
+ public static ContentCountry getPreferredContentCountry(@NonNull final Context context) {
final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.content_country_key),
context.getString(R.string.default_localization_key));
@@ -92,41 +95,43 @@ public final class Localization {
return new ContentCountry(contentCountry);
}
- public static Locale getPreferredLocale(final Context context) {
+ public static Locale getPreferredLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.content_language_key);
}
- public static Locale getAppLocale(final Context context) {
+ public static Locale getAppLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.app_language_key);
}
- public static String localizeNumber(final Context context, final long number) {
+ public static String localizeNumber(@NonNull final Context context, final long number) {
return localizeNumber(context, (double) number);
}
- public static String localizeNumber(final Context context, final double number) {
+ public static String localizeNumber(@NonNull final Context context, final double number) {
final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context));
return nf.format(number);
}
- public static String formatDate(final OffsetDateTime offsetDateTime, final Context context) {
+ public static String formatDate(@NonNull final Context context,
+ @NonNull final OffsetDateTime offsetDateTime) {
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(getAppLocale(context)).format(offsetDateTime
.atZoneSameInstant(ZoneId.systemDefault()));
}
@SuppressLint("StringFormatInvalid")
- public static String localizeUploadDate(final Context context,
- final OffsetDateTime offsetDateTime) {
- return context.getString(R.string.upload_date_text, formatDate(offsetDateTime, context));
+ public static String localizeUploadDate(@NonNull final Context context,
+ @NonNull final OffsetDateTime offsetDateTime) {
+ return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime));
}
- public static String localizeViewCount(final Context context, final long viewCount) {
+ public static String localizeViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
localizeNumber(context, viewCount));
}
- public static String localizeStreamCount(final Context context, final long streamCount) {
+ public static String localizeStreamCount(@NonNull final Context context,
+ final long streamCount) {
switch ((int) streamCount) {
case (int) ListExtractor.ITEM_COUNT_UNKNOWN:
return "";
@@ -140,7 +145,8 @@ public final class Localization {
}
}
- public static String localizeStreamCountMini(final Context context, final long streamCount) {
+ public static String localizeStreamCountMini(@NonNull final Context context,
+ final long streamCount) {
switch ((int) streamCount) {
case (int) ListExtractor.ITEM_COUNT_UNKNOWN:
return "";
@@ -153,12 +159,13 @@ public final class Localization {
}
}
- public static String localizeWatchingCount(final Context context, final long watchingCount) {
+ public static String localizeWatchingCount(@NonNull final Context context,
+ final long watchingCount) {
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
localizeNumber(context, watchingCount));
}
- public static String shortCount(final Context context, final long count) {
+ public static String shortCount(@NonNull final Context context, final long count) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return CompactDecimalFormat.getInstance(getAppLocale(context),
CompactDecimalFormat.CompactStyle.SHORT).format(count);
@@ -179,37 +186,79 @@ public final class Localization {
}
}
- public static String listeningCount(final Context context, final long listeningCount) {
+ public static String listeningCount(@NonNull final Context context, final long listeningCount) {
return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount,
shortCount(context, listeningCount));
}
- public static String shortWatchingCount(final Context context, final long watchingCount) {
+ public static String shortWatchingCount(@NonNull final Context context,
+ final long watchingCount) {
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
shortCount(context, watchingCount));
}
- public static String shortViewCount(final Context context, final long viewCount) {
+ public static String shortViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
shortCount(context, viewCount));
}
- public static String shortSubscriberCount(final Context context, final long subscriberCount) {
+ public static String shortSubscriberCount(@NonNull final Context context,
+ final long subscriberCount) {
return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount,
shortCount(context, subscriberCount));
}
- public static String downloadCount(final Context context, final int downloadCount) {
+ public static String downloadCount(@NonNull final Context context, final int downloadCount) {
return getQuantity(context, R.plurals.download_finished_notification, 0,
downloadCount, shortCount(context, downloadCount));
}
- public static String deletedDownloadCount(final Context context, final int deletedCount) {
+ public static String deletedDownloadCount(@NonNull final Context context,
+ final int deletedCount) {
return getQuantity(context, R.plurals.deleted_downloads_toast, 0,
deletedCount, shortCount(context, deletedCount));
}
+ public static String replyCount(@NonNull final Context context, final int replyCount) {
+ return getQuantity(context, R.plurals.replies, 0, replyCount,
+ String.valueOf(replyCount));
+ }
+
+ /**
+ * @param context the Android context
+ * @param likeCount the like count, possibly negative if unknown
+ * @return if {@code likeCount} is smaller than {@code 0}, the string {@code "-"}, otherwise
+ * the result of calling {@link #shortCount(Context, long)} on the like count
+ */
+ public static String likeCount(@NonNull final Context context, final int likeCount) {
+ if (likeCount < 0) {
+ return "-";
+ } else {
+ return shortCount(context, likeCount);
+ }
+ }
+
+ /**
+ * Get a readable text for a duration in the format {@code days:hours:minutes:seconds}.
+ * Prepended zeros are removed.
+ * @param duration the duration in seconds
+ * @return a formatted duration String or {@code 0:00} if the duration is zero.
+ */
public static String getDurationString(final long duration) {
+ return getDurationString(duration, true, false);
+ }
+
+ /**
+ * Get a readable text for a duration in the format {@code days:hours:minutes:seconds+}.
+ * Prepended zeros are removed. If the given duration is incomplete, a plus is appended to the
+ * duration string.
+ * @param duration the duration in seconds
+ * @param isDurationComplete whether the given duration is complete or whether info is missing
+ * @param showDurationPrefix whether the duration-prefix shall be shown
+ * @return a formatted duration String or {@code 0:00} if the duration is zero.
+ */
+ public static String getDurationString(final long duration, final boolean isDurationComplete,
+ final boolean showDurationPrefix) {
final String output;
final long days = duration / (24 * 60 * 60L); /* greater than a day */
@@ -227,7 +276,9 @@ public final class Localization {
} else {
output = String.format(Locale.US, "%d:%02d", minutes, seconds);
}
- return output;
+ final String durationPrefix = showDurationPrefix ? "⏱ " : "";
+ final String durationPostfix = isDurationComplete ? "" : "+";
+ return durationPrefix + output + durationPostfix;
}
/**
@@ -241,7 +292,8 @@ public final class Localization {
* @return duration in a human readable string.
*/
@NonNull
- public static String localizeDuration(final Context context, final int durationInSecs) {
+ public static String localizeDuration(@NonNull final Context context,
+ final int durationInSecs) {
if (durationInSecs < 0) {
throw new IllegalArgumentException("duration can not be negative");
}
@@ -278,7 +330,7 @@ public final class Localization {
* @param track an {@link AudioStream} of the track
* @return the localized name of the audio track
*/
- public static String audioTrackName(final Context context, final AudioStream track) {
+ public static String audioTrackName(@NonNull final Context context, final AudioStream track) {
final String name;
if (track.getAudioLocale() != null) {
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
@@ -298,7 +350,8 @@ public final class Localization {
}
@Nullable
- private static String audioTrackType(final Context context, final AudioTrackType trackType) {
+ private static String audioTrackType(@NonNull final Context context,
+ final AudioTrackType trackType) {
switch (trackType) {
case ORIGINAL:
return context.getString(R.string.audio_track_type_original);
@@ -314,20 +367,45 @@ public final class Localization {
// Pretty Time
//////////////////////////////////////////////////////////////////////////*/
- public static void initPrettyTime(final PrettyTime time) {
+ public static void initPrettyTime(@NonNull final PrettyTime time) {
prettyTime = time;
// Do not use decades as YouTube doesn't either.
prettyTime.removeUnit(Decade.class);
}
- public static PrettyTime resolvePrettyTime(final Context context) {
+ public static PrettyTime resolvePrettyTime(@NonNull final Context context) {
return new PrettyTime(getAppLocale(context));
}
- public static String relativeTime(final OffsetDateTime offsetDateTime) {
+ public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) {
return prettyTime.formatUnrounded(offsetDateTime);
}
+ /**
+ * @param context the Android context; if {@code null} then even if in debug mode and the
+ * setting is enabled, {@code textual} will not be shown next to {@code parsed}
+ * @param parsed the textual date or time ago parsed by NewPipeExtractor, or {@code null} if
+ * the extractor could not parse it
+ * @param textual the original textual date or time ago string as provided by services
+ * @return {@link #relativeTime(OffsetDateTime)} is used if {@code parsed != null}, otherwise
+ * {@code textual} is returned. If in debug mode, {@code context != null},
+ * {@code parsed != null} and the relevant setting is enabled, {@code textual} will
+ * be appended to the returned string for debugging purposes.
+ */
+ public static String relativeTimeOrTextual(@Nullable final Context context,
+ @Nullable final DateWrapper parsed,
+ final String textual) {
+ if (parsed == null) {
+ return textual;
+ } else if (DEBUG && context != null && PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.show_original_time_ago_key), false)) {
+ return relativeTime(parsed.offsetDateTime()) + " (" + textual + ")";
+ } else {
+ return relativeTime(parsed.offsetDateTime());
+ }
+ }
+
public static void assureCorrectAppLanguage(final Context c) {
final Resources res = c.getResources();
final DisplayMetrics dm = res.getDisplayMetrics();
@@ -336,7 +414,8 @@ public final class Localization {
res.updateConfiguration(conf, dm);
}
- private static Locale getLocaleFromPrefs(final Context context, @StringRes final int prefKey) {
+ private static Locale getLocaleFromPrefs(@NonNull final Context context,
+ @StringRes final int prefKey) {
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
final String defaultKey = context.getString(R.string.default_localization_key);
final String languageCode = sp.getString(context.getString(prefKey), defaultKey);
@@ -352,8 +431,10 @@ public final class Localization {
return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue();
}
- private static String getQuantity(final Context context, @PluralsRes final int pluralId,
- @StringRes final int zeroCaseStringId, final long count,
+ private static String getQuantity(@NonNull final Context context,
+ @PluralsRes final int pluralId,
+ @StringRes final int zeroCaseStringId,
+ final long count,
final String formattedCount) {
if (count == 0) {
return context.getString(zeroCaseStringId);
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index b0d7dcf73..5dee32371 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -1,5 +1,6 @@
package org.schabi.newpipe.util;
+import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
import android.annotation.SuppressLint;
@@ -17,6 +18,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
@@ -29,8 +31,10 @@ import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.about.AboutActivity;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.download.DownloadActivity;
+import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
+import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
@@ -41,6 +45,7 @@ import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.channel.ChannelFragment;
+import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
import org.schabi.newpipe.fragments.list.kiosk.KioskFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
@@ -476,6 +481,35 @@ public final class NavigationHelper {
item.getServiceId(), uploaderUrl, item.getUploaderName());
}
+ /**
+ * Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()}
+ * of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong.
+ *
+ * @param activity the activity with the fragment manager and in which to show the snackbar
+ * @param comment the comment whose uploader/author will be opened
+ */
+ public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity,
+ @NonNull final CommentsInfoItem comment) {
+ if (isEmpty(comment.getUploaderUrl())) {
+ return;
+ }
+ try {
+ openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(),
+ comment.getUploaderUrl(), comment.getUploaderName());
+ } catch (final Exception e) {
+ ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
+ }
+ }
+
+ public static void openCommentRepliesFragment(@NonNull final FragmentActivity activity,
+ @NonNull final CommentsInfoItem comment) {
+ defaultTransaction(activity.getSupportFragmentManager())
+ .replace(R.id.fragment_holder, new CommentRepliesFragment(comment),
+ CommentRepliesFragment.TAG)
+ .addToBackStack(CommentRepliesFragment.TAG)
+ .commit();
+ }
+
public static void openPlaylistFragment(final FragmentManager fragmentManager,
final int serviceId, final String url,
@NonNull final String name) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java b/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java
deleted file mode 100644
index f96bb0d54..000000000
--- a/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.schabi.newpipe.util;
-
-import org.schabi.newpipe.extractor.InfoItem;
-import org.schabi.newpipe.extractor.ListInfo;
-import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-public class RelatedItemInfo extends ListInfo {
- public RelatedItemInfo(final int serviceId, final ListLinkHandler listUrlIdHandler,
- final String name) {
- super(serviceId, listUrlIdHandler, name);
- }
-
- public static RelatedItemInfo getInfo(final StreamInfo info) {
- final ListLinkHandler handler = new ListLinkHandler(
- info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null);
- final RelatedItemInfo relatedItemInfo = new RelatedItemInfo(
- info.getServiceId(), handler, info.getName());
- final List relatedItems = new ArrayList<>(info.getRelatedItems());
- relatedItemInfo.setRelatedItems(relatedItems);
- return relatedItemInfo;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
index 5a54b29d2..3ea19fa4f 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
@@ -1,97 +1,39 @@
package org.schabi.newpipe.util
import android.content.pm.PackageManager
-import android.content.pm.Signature
import androidx.core.content.pm.PackageInfoCompat
import org.schabi.newpipe.App
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
import org.schabi.newpipe.error.UserAction
-import java.security.MessageDigest
-import java.security.NoSuchAlgorithmException
-import java.security.cert.CertificateEncodingException
-import java.security.cert.CertificateException
-import java.security.cert.CertificateFactory
-import java.security.cert.X509Certificate
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
object ReleaseVersionUtil {
// Public key of the certificate that is used in NewPipe release versions
- private const val RELEASE_CERT_PUBLIC_KEY_SHA1 =
- "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15"
+ private const val RELEASE_CERT_PUBLIC_KEY_SHA256 =
+ "cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab"
- @JvmStatic
- fun isReleaseApk(): Boolean {
- return certificateSHA1Fingerprint == RELEASE_CERT_PUBLIC_KEY_SHA1
- }
-
- /**
- * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
- *
- * @return String with the APK's SHA1 fingerprint in hexadecimal
- */
- private val certificateSHA1Fingerprint: String
- get() {
- val app = App.getApp()
- val signatures: List = try {
- PackageInfoCompat.getSignatures(app.packageManager, app.packageName)
- } catch (e: PackageManager.NameNotFoundException) {
- showRequestError(app, e, "Could not find package info")
- return ""
- }
- if (signatures.isEmpty()) {
- return ""
- }
- val x509cert = try {
- val cf = CertificateFactory.getInstance("X509")
- cf.generateCertificate(signatures[0].toByteArray().inputStream()) as X509Certificate
- } catch (e: CertificateException) {
- showRequestError(app, e, "Certificate error")
- return ""
- }
-
- return try {
- val md = MessageDigest.getInstance("SHA1")
- val publicKey = md.digest(x509cert.encoded)
- byte2HexFormatted(publicKey)
- } catch (e: NoSuchAlgorithmException) {
- showRequestError(app, e, "Could not retrieve SHA1 key")
- ""
- } catch (e: CertificateEncodingException) {
- showRequestError(app, e, "Could not retrieve SHA1 key")
- ""
- }
- }
-
- private fun byte2HexFormatted(arr: ByteArray): String {
- val str = StringBuilder(arr.size * 2)
- for (i in arr.indices) {
- var h = Integer.toHexString(arr[i].toInt())
- val l = h.length
- if (l == 1) {
- h = "0$h"
- }
- if (l > 2) {
- h = h.substring(l - 2, l)
- }
- str.append(h.uppercase())
- if (i < arr.size - 1) {
- str.append(':')
- }
- }
- return str.toString()
- }
-
- private fun showRequestError(app: App, e: Exception, request: String) {
- createNotification(
- app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, request)
+ @OptIn(ExperimentalStdlibApi::class)
+ val isReleaseApk by lazy {
+ @Suppress("NewApi")
+ val certificates = mapOf(
+ RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256
)
+ val app = App.getApp()
+ try {
+ PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false)
+ } catch (e: PackageManager.NameNotFoundException) {
+ createNotification(
+ app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info")
+ )
+ false
+ }
}
fun isLastUpdateCheckExpired(expiry: Long): Boolean {
- return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
+ return Instant.ofEpochSecond(expiry) < Instant.now()
}
/**
diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
index 75d9a3892..69dc697fe 100644
--- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
@@ -11,7 +11,6 @@ import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
-import java.util.Comparator;
import java.util.List;
public class SecondaryStreamHelper {
@@ -43,42 +42,27 @@ public class SecondaryStreamHelper {
@NonNull final List audioStreams,
@NonNull final VideoStream videoStream) {
final MediaFormat mediaFormat = videoStream.getFormat();
- if (mediaFormat == null) {
+
+ if (mediaFormat == MediaFormat.WEBM) {
+ return audioStreams
+ .stream()
+ .filter(audioStream -> audioStream.getFormat() == MediaFormat.WEBMA
+ || audioStream.getFormat() == MediaFormat.WEBMA_OPUS)
+ .max(ListHelper.getAudioFormatComparator(MediaFormat.WEBMA,
+ ListHelper.isLimitingDataUsage(context)))
+ .orElse(null);
+
+ } else if (mediaFormat == MediaFormat.MPEG_4) {
+ return audioStreams
+ .stream()
+ .filter(audioStream -> audioStream.getFormat() == MediaFormat.M4A)
+ .max(ListHelper.getAudioFormatComparator(MediaFormat.M4A,
+ ListHelper.isLimitingDataUsage(context)))
+ .orElse(null);
+
+ } else {
return null;
}
-
- switch (mediaFormat) {
- case WEBM:
- case MPEG_4: // Is MPEG-4 DASH?
- break;
- default:
- return null;
- }
-
- final boolean m4v = mediaFormat == MediaFormat.MPEG_4;
- final boolean isLimitingDataUsage = ListHelper.isLimitingDataUsage(context);
-
- Comparator comparator = ListHelper.getAudioFormatComparator(
- m4v ? MediaFormat.M4A : MediaFormat.WEBMA, isLimitingDataUsage);
- int preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
- audioStreams, comparator);
-
- if (preferredAudioStreamIndex == -1) {
- if (m4v) {
- return null;
- }
-
- comparator = ListHelper.getAudioFormatComparator(
- MediaFormat.WEBMA_OPUS, isLimitingDataUsage);
- preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
- audioStreams, comparator);
-
- if (preferredAudioStreamIndex == -1) {
- return null;
- }
- }
-
- return audioStreams.get(preferredAudioStreamIndex);
}
public T getStream() {
diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java
index acd019ba0..c712157b3 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java
@@ -144,6 +144,19 @@ public final class ServiceHelper {
.orElse("");
}
+ /**
+ * @param serviceId the id of the service
+ * @return the service corresponding to the provided id
+ * @throws java.util.NoSuchElementException if there is no service with the provided id
+ */
+ @NonNull
+ public static StreamingService getServiceById(final int serviceId) {
+ return ServiceList.all().stream()
+ .filter(s -> s.getServiceId() == serviceId)
+ .findFirst()
+ .orElseThrow();
+ }
+
public static void setSelectedServiceId(final Context context, final int serviceId) {
String serviceName;
try {
diff --git a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
index 91dc5f35b..61fdb602f 100644
--- a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
+++ b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
@@ -27,6 +27,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.core.os.BundleCompat;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.MainActivity;
@@ -82,7 +83,8 @@ public final class StateSaver {
return null;
}
- final SavedState savedState = outState.getParcelable(KEY_SAVED_STATE);
+ final SavedState savedState = BundleCompat.getParcelable(
+ outState, KEY_SAVED_STATE, SavedState.class);
if (savedState == null) {
return null;
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java
new file mode 100644
index 000000000..acc515dd6
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java
@@ -0,0 +1,15 @@
+package org.schabi.newpipe.util.debounce;
+
+import org.schabi.newpipe.error.ErrorInfo;
+
+public interface DebounceSavable {
+
+ /**
+ * Execute operations to save the data.
+ * Must set {@link DebounceSaver#setIsModified(boolean)} false in this method manually
+ * after the data has been saved.
+ */
+ void saveImmediate();
+
+ void showError(ErrorInfo errorInfo);
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java
new file mode 100644
index 000000000..5bd5cdd55
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java
@@ -0,0 +1,81 @@
+package org.schabi.newpipe.util.debounce;
+
+import org.schabi.newpipe.error.ErrorInfo;
+import org.schabi.newpipe.error.UserAction;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.subjects.PublishSubject;
+
+public class DebounceSaver {
+
+ private final long saveDebounceMillis;
+
+ private final PublishSubject debouncedSaveSignal;
+
+ private final DebounceSavable debounceSavable;
+
+ // Has the object been modified
+ private final AtomicBoolean isModified;
+
+ // Default 10 seconds
+ private static final long DEFAULT_SAVE_DEBOUNCE_MILLIS = 10000;
+
+
+ /**
+ * Creates a new {@code DebounceSaver}.
+ *
+ * @param saveDebounceMillis Save the object milliseconds later after the last change
+ * occurred.
+ * @param debounceSavable The object containing data to be saved.
+ */
+ public DebounceSaver(final long saveDebounceMillis, final DebounceSavable debounceSavable) {
+ this.saveDebounceMillis = saveDebounceMillis;
+ debouncedSaveSignal = PublishSubject.create();
+ this.debounceSavable = debounceSavable;
+ this.isModified = new AtomicBoolean();
+ }
+
+ /**
+ * Creates a new {@code DebounceSaver}. Save the object 10 seconds later after the last change
+ * occurred.
+ *
+ * @param debounceSavable The object containing data to be saved.
+ */
+ public DebounceSaver(final DebounceSavable debounceSavable) {
+ this(DEFAULT_SAVE_DEBOUNCE_MILLIS, debounceSavable);
+ }
+
+ public boolean getIsModified() {
+ return isModified.get();
+ }
+
+ public void setNoChangesToSave() {
+ isModified.set(false);
+ }
+
+ public PublishSubject getDebouncedSaveSignal() {
+ return debouncedSaveSignal;
+ }
+
+ public Disposable getDebouncedSaver() {
+ return debouncedSaveSignal
+ .debounce(saveDebounceMillis, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(ignored -> debounceSavable.saveImmediate(), throwable ->
+ debounceSavable.showError(new ErrorInfo(throwable,
+ UserAction.SOMETHING_ELSE, "Debounced saver")));
+ }
+
+ public void setHasChangesToSave() {
+ if (isModified == null || debouncedSaveSignal == null) {
+ return;
+ }
+
+ isModified.set(true);
+ debouncedSaveSignal.onNext(System.currentTimeMillis());
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java
new file mode 100644
index 000000000..184b73304
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java
@@ -0,0 +1,193 @@
+package org.schabi.newpipe.util.text;
+
+import android.graphics.Paint;
+import android.text.Layout;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.text.HtmlCompat;
+
+import org.schabi.newpipe.extractor.StreamingService;
+import org.schabi.newpipe.extractor.stream.Description;
+
+import java.util.function.Consumer;
+
+
+import io.reactivex.rxjava3.disposables.CompositeDisposable;
+
+/**
+ * Class to ellipsize text inside a {@link TextView}.
+ * This class provides all utils to automatically ellipsize and expand a text
+ */
+public final class TextEllipsizer {
+ private static final int EXPANDED_LINES = Integer.MAX_VALUE;
+ private static final String ELLIPSIS = "…";
+
+ @NonNull private final CompositeDisposable disposable = new CompositeDisposable();
+
+ @NonNull private final TextView view;
+ private final int maxLines;
+ @NonNull private Description content;
+ @Nullable private StreamingService streamingService;
+ @Nullable private String streamUrl;
+ private boolean isEllipsized = false;
+ @Nullable private Boolean canBeEllipsized = null;
+
+ @NonNull private final Paint paintAtContentSize = new Paint();
+ private final float ellipsisWidthPx;
+ @Nullable private Consumer stateChangeListener = null;
+ @Nullable private Consumer onContentChanged;
+
+ public TextEllipsizer(@NonNull final TextView view,
+ final int maxLines,
+ @Nullable final StreamingService streamingService) {
+ this.view = view;
+ this.maxLines = maxLines;
+ this.content = Description.EMPTY_DESCRIPTION;
+ this.streamingService = streamingService;
+
+ paintAtContentSize.setTextSize(view.getTextSize());
+ ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
+ }
+
+ public void setOnContentChanged(@Nullable final Consumer onContentChanged) {
+ this.onContentChanged = onContentChanged;
+ }
+
+ public void setContent(@NonNull final Description content) {
+ this.content = content;
+ canBeEllipsized = null;
+ linkifyContentView(v -> {
+ final int currentMaxLines = view.getMaxLines();
+ view.setMaxLines(EXPANDED_LINES);
+ canBeEllipsized = view.getLineCount() > maxLines;
+ view.setMaxLines(currentMaxLines);
+ if (onContentChanged != null) {
+ onContentChanged.accept(canBeEllipsized);
+ }
+ });
+ }
+
+ public void setStreamUrl(@Nullable final String streamUrl) {
+ this.streamUrl = streamUrl;
+ }
+
+ public void setStreamingService(@NonNull final StreamingService streamingService) {
+ this.streamingService = streamingService;
+ }
+
+ /**
+ * Expand the {@link TextEllipsizer#content} to its full length.
+ */
+ public void expand() {
+ view.setMaxLines(EXPANDED_LINES);
+ linkifyContentView(v -> isEllipsized = false);
+ }
+
+ /**
+ * Shorten the {@link TextEllipsizer#content} to the given number of
+ * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}'
+ * if the text was shorted.
+ */
+ public void ellipsize() {
+ // expand text to see whether it is necessary to ellipsize the text
+ view.setMaxLines(EXPANDED_LINES);
+ linkifyContentView(v -> {
+ final CharSequence charSeqText = view.getText();
+ if (charSeqText != null && view.getLineCount() > maxLines) {
+ // Note that converting to String removes spans (i.e. links), but that's something
+ // we actually want since when the text is ellipsized we want all clicks on the
+ // comment to expand the comment, not to open links.
+ final String text = charSeqText.toString();
+
+ final Layout layout = view.getLayout();
+ final float lineWidth = layout.getLineWidth(maxLines - 1);
+ final float layoutWidth = layout.getWidth();
+ final int lineStart = layout.getLineStart(maxLines - 1);
+ final int lineEnd = layout.getLineEnd(maxLines - 1);
+
+ // remove characters up until there is enough space for the ellipsis
+ // (also summing 2 more pixels, just to be sure to avoid float rounding errors)
+ int end = lineEnd;
+ float removedCharactersWidth = 0.0f;
+ while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
+ && end >= lineStart) {
+ end -= 1;
+ // recalculate each time to account for ligatures or other similar things
+ removedCharactersWidth = paintAtContentSize.measureText(
+ text.substring(end, lineEnd));
+ }
+
+ // remove trailing spaces and newlines
+ while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
+ end -= 1;
+ }
+
+ final String newVal = text.substring(0, end) + ELLIPSIS;
+ view.setText(newVal);
+ isEllipsized = true;
+ } else {
+ isEllipsized = false;
+ }
+ view.setMaxLines(maxLines);
+ });
+ }
+
+ /**
+ * Toggle the view between the ellipsized and expanded state.
+ */
+ public void toggle() {
+ if (isEllipsized) {
+ expand();
+ } else {
+ ellipsize();
+ }
+ }
+
+ /**
+ * Whether the {@link #view} can be ellipsized.
+ * This is only the case when the {@link #content} has more lines
+ * than allowed via {@link #maxLines}.
+ * @return {@code true} if the {@link #content} has more lines than allowed via
+ * {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into
+ * the {@link #view} without being shortened and {@code null} if the initialization is not
+ * completed yet.
+ */
+ @Nullable
+ public Boolean canBeEllipsized() {
+ return canBeEllipsized;
+ }
+
+ private void linkifyContentView(final Consumer consumer) {
+ final boolean oldState = isEllipsized;
+ disposable.clear();
+ TextLinkifier.fromDescription(view, content,
+ HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable,
+ v -> {
+ consumer.accept(v);
+ notifyStateChangeListener(oldState);
+ });
+
+ }
+
+ /**
+ * Add a listener which is called when the given content is changed,
+ * either from ellipsized to full or vice versa.
+ * @param listener The listener to be called, or {@code null} to remove it.
+ * The Boolean parameter is the new state.
+ * Ellipsized content is represented as {@code true},
+ * normal or full content by {@code false}.
+ */
+ public void setStateChangeListener(@Nullable final Consumer listener) {
+ this.stateChangeListener = listener;
+ }
+
+ private void notifyStateChangeListener(final boolean oldState) {
+ if (oldState != isEllipsized && stateChangeListener != null) {
+ stateChangeListener.accept(isEllipsized);
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java
index e59a3dc05..1419ac85a 100644
--- a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java
+++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java
@@ -92,7 +92,7 @@ public final class TextLinkifier {
* {@link HtmlCompat#fromHtml(String, int)}.
*
*
- * @param textView the {@link TextView} to set the the HTML string block linked
+ * @param textView the {@link TextView} to set the HTML string block linked
* @param htmlBlock the HTML string block to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String,
* int)} will be called
diff --git a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
index e3d142916..8554e7194 100644
--- a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
+++ b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
@@ -80,10 +80,10 @@ class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context,
updatePathShape()
}
- override fun onDraw(canvas: Canvas?) {
+ override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
- canvas?.clipPath(shapePath)
- canvas?.drawPath(shapePath, backgroundPaint)
+ canvas.clipPath(shapePath)
+ canvas.drawPath(shapePath, backgroundPaint)
}
}
diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
index 009a4f4be..45211211f 100755
--- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
+++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
@@ -23,7 +23,6 @@ import android.os.Handler;
import android.os.Handler.Callback;
import android.os.IBinder;
import android.os.Message;
-import android.os.Parcelable;
import android.util.Log;
import android.widget.Toast;
@@ -36,6 +35,7 @@ import androidx.core.app.NotificationCompat.Builder;
import androidx.core.app.PendingIntentCompat;
import androidx.core.app.ServiceCompat;
import androidx.core.content.ContextCompat;
+import androidx.core.content.IntentCompat;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
@@ -49,6 +49,7 @@ import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.MissionRecoveryInfo;
@@ -359,29 +360,29 @@ public class DownloadManagerService extends Service {
*/
public static void startMission(Context context, String[] urls, StoredFileHelper storage,
char kind, int threads, String source, String psName,
- String[] psArgs, long nearLength, MissionRecoveryInfo[] recoveryInfo) {
- Intent intent = new Intent(context, DownloadManagerService.class);
- intent.setAction(Intent.ACTION_RUN);
- intent.putExtra(EXTRA_URLS, urls);
- intent.putExtra(EXTRA_KIND, kind);
- intent.putExtra(EXTRA_THREADS, threads);
- intent.putExtra(EXTRA_SOURCE, source);
- intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName);
- intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs);
- intent.putExtra(EXTRA_NEAR_LENGTH, nearLength);
- intent.putExtra(EXTRA_RECOVERY_INFO, recoveryInfo);
-
- intent.putExtra(EXTRA_PARENT_PATH, storage.getParentUri());
- intent.putExtra(EXTRA_PATH, storage.getUri());
- intent.putExtra(EXTRA_STORAGE_TAG, storage.getTag());
+ String[] psArgs, long nearLength,
+ ArrayList recoveryInfo) {
+ final Intent intent = new Intent(context, DownloadManagerService.class)
+ .setAction(Intent.ACTION_RUN)
+ .putExtra(EXTRA_URLS, urls)
+ .putExtra(EXTRA_KIND, kind)
+ .putExtra(EXTRA_THREADS, threads)
+ .putExtra(EXTRA_SOURCE, source)
+ .putExtra(EXTRA_POSTPROCESSING_NAME, psName)
+ .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs)
+ .putExtra(EXTRA_NEAR_LENGTH, nearLength)
+ .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo)
+ .putExtra(EXTRA_PARENT_PATH, storage.getParentUri())
+ .putExtra(EXTRA_PATH, storage.getUri())
+ .putExtra(EXTRA_STORAGE_TAG, storage.getTag());
context.startService(intent);
}
private void startMission(Intent intent) {
String[] urls = intent.getStringArrayExtra(EXTRA_URLS);
- Uri path = intent.getParcelableExtra(EXTRA_PATH);
- Uri parentPath = intent.getParcelableExtra(EXTRA_PARENT_PATH);
+ Uri path = IntentCompat.getParcelableExtra(intent, EXTRA_PATH, Uri.class);
+ Uri parentPath = IntentCompat.getParcelableExtra(intent, EXTRA_PARENT_PATH, Uri.class);
int threads = intent.getIntExtra(EXTRA_THREADS, 1);
char kind = intent.getCharExtra(EXTRA_KIND, '?');
String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME);
@@ -389,7 +390,9 @@ public class DownloadManagerService extends Service {
String source = intent.getStringExtra(EXTRA_SOURCE);
long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0);
String tag = intent.getStringExtra(EXTRA_STORAGE_TAG);
- Parcelable[] parcelRecovery = intent.getParcelableArrayExtra(EXTRA_RECOVERY_INFO);
+ final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO,
+ MissionRecoveryInfo.class);
+ Objects.requireNonNull(recovery);
StoredFileHelper storage;
try {
@@ -404,15 +407,11 @@ public class DownloadManagerService extends Service {
else
ps = Postprocessing.getAlgorithm(psName, psArgs);
- MissionRecoveryInfo[] recovery = new MissionRecoveryInfo[parcelRecovery.length];
- for (int i = 0; i < parcelRecovery.length; i++)
- recovery[i] = (MissionRecoveryInfo) parcelRecovery[i];
-
final DownloadMission mission = new DownloadMission(urls, storage, kind, ps);
mission.threadCount = threads;
mission.source = source;
mission.nearLength = nearLength;
- mission.recoveryInfo = recovery;
+ mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]);
if (ps != null)
ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this));
diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
index 23f1bf6a7..31e7f663d 100644
--- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
+++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
@@ -490,7 +490,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb
showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed);
return;
case ERROR_INSUFFICIENT_STORAGE:
- msg = R.string.error_insufficient_storage;
+ msg = R.string.error_insufficient_storage_left;
break;
case ERROR_UNKNOWN_EXCEPTION:
if (mission.errObject != null) {
diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java
index 3cfa22bd9..86a08c57f 100644
--- a/app/src/main/java/us/shandian/giga/util/Utility.java
+++ b/app/src/main/java/us/shandian/giga/util/Utility.java
@@ -2,6 +2,8 @@ package us.shandian.giga.util;
import android.content.Context;
import android.os.Build;
+import android.os.Environment;
+import android.os.StatFs;
import android.util.Log;
import androidx.annotation.ColorInt;
@@ -26,10 +28,8 @@ import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.net.HttpURLConnection;
import java.util.Locale;
-import java.util.Random;
import okio.ByteString;
-import us.shandian.giga.get.DownloadMission;
public class Utility {
diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml
new file mode 120000
index 000000000..70228ee1d
--- /dev/null
+++ b/app/src/main/res/layout-land/list_stream_card_item.xml
@@ -0,0 +1 @@
+../layout/list_stream_item.xml
\ No newline at end of file
diff --git a/app/src/main/res/layout/comment_replies_header.xml b/app/src/main/res/layout/comment_replies_header.xml
new file mode 100644
index 000000000..ed5ba1a10
--- /dev/null
+++ b/app/src/main/res/layout/comment_replies_header.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_comments.xml b/app/src/main/res/layout/fragment_comments.xml
index b1b644d8c..2a8c747cd 100644
--- a/app/src/main/res/layout/fragment_comments.xml
+++ b/app/src/main/res/layout/fragment_comments.xml
@@ -9,7 +9,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
- tools:listitem="@layout/list_comments_item" />
+ tools:listitem="@layout/list_comment_item" />
+ android:src="@drawable/ic_pin" />
+ tools:text="Author Name, Lorem ipsum • 5 months ago" />
-
+ tools:text="@tools:sample/lorem/random[1]" />
+ android:src="@drawable/ic_heart" />
-
+ android:layout_alignParentEnd="true"
+ android:layout_marginStart="@dimen/video_item_detail_heart_margin"
+ android:minHeight="0dp"
+ tools:text="543 replies" />
diff --git a/app/src/main/res/layout/list_comments_mini_item.xml b/app/src/main/res/layout/list_comments_mini_item.xml
deleted file mode 100644
index 606a237c5..000000000
--- a/app/src/main/res/layout/list_comments_mini_item.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/list_playlist_bookmark_item.xml b/app/src/main/res/layout/list_playlist_bookmark_item.xml
new file mode 100644
index 000000000..6aabd4d07
--- /dev/null
+++ b/app/src/main/res/layout/list_playlist_bookmark_item.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/playlist_header.xml b/app/src/main/res/layout/playlist_header.xml
index 9c038db3a..c761240d9 100644
--- a/app/src/main/res/layout/playlist_header.xml
+++ b/app/src/main/res/layout/playlist_header.xml
@@ -80,10 +80,32 @@
tools:text="234 videos" />
+
+
+
+
+ android:layout_below="@id/playlist_description_read_more">
-
+ android:id="@+id/summary"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:clickable="false"
+ android:focusable="false"
+ android:gravity="center"
+ android:text="@string/notification_actions_summary"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.0"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
-
+
-
+
-
+
-
+
-
+
-
+
diff --git a/app/src/main/res/values-ar-rLY/strings.xml b/app/src/main/res/values-ar-rLY/strings.xml
index aca74e743..077cf1106 100644
--- a/app/src/main/res/values-ar-rLY/strings.xml
+++ b/app/src/main/res/values-ar-rLY/strings.xml
@@ -78,7 +78,7 @@
بليون
تعذر تحميل موجز \'%s\'.
؟
- التحقق من وجود تحديثات
+ التحقق من وجود تحديثات
مثيلات خوادم پيرتيوب
+100 فيديو
ألف
@@ -271,7 +271,7 @@
يلغي السجل الحالي والاشتراكات وقوائم التشغيل والإعدادات (اختياريًا)
تعطل التطبيق / واجهة المستخدم
إعادة التسمية
- لم يتبقى مساحة في الجهاز
+ لم يتبقى مساحة في الجهاز
تعذر إعداد قائمة التنزيل
اختر مجلد التنزيل لملفات الفيديو
تم تعطيل الإشعارات
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 6bfe11eaa..82173d758 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -407,7 +407,7 @@
لا يمكن الكتابة فوق الملف
هناك تنزيل معلق بهذا الاسم
تم إغلاق NewPipe أثناء العمل على الملف
- لم يتبقى مساحة في الجهاز
+ لم يتبقى مساحة في الجهاز
تم فقد التقدم بسبب حذف الملف
انتهى وقت الاتصال
هل تريد محو سجل التنزيل، أم تريد حذف جميع الملفات التي تم تنزيلها؟
@@ -584,7 +584,7 @@
خلط
تكرار
يمكنك تحديد ثلاثة إجراءات كحد أقصى لإظهارها في الإشعار المضغوط!
- قم بتحرير كل إشعار أدناه من خلال النقر عليه. حدد ما يصل إلى ثلاثة منها لتظهر في الإشعار المضغوط باستخدام مربعات الاختيار الموجودة على اليمين
+ قم بتحرير كل إجراء إعلام أدناه من خلال النقر عليه. حدد ما يصل إلى ثلاثة منها ليتم عرضها في الإشعار المضغوط باستخدام مربعات الاختيار الموجودة على اليمين.
زر الإجراء الخامس
زر الإجراء الرابع
زر الإجراء الثالث
@@ -700,7 +700,7 @@
وضع التالي على قائمة الانتظار
تم وضع التالي على قائمة الانتظار
جاري المعالجة ... قد يستغرق لحظة
- التحقق من وجود تحديثات
+ التحقق من وجود تحديثات
التحقق يدويا من وجود إصدارات جديدة
جاري التحقق من وجود تحديثات…
عناصر تغذية جديدة
@@ -858,4 +858,26 @@
مشاركة قائمة التشغيل
شارك تفاصيل قائمة التشغيل مثل اسم قائمة التشغيل وعناوين الفيديو أو كقائمة بسيطة من عناوين URL للفيديو
- %1$s: %2$s
+
+ - رد %s
+ - رد %s
+ - ردان%s
+ - ردود%s
+ - ردود %s
+ - ردود %s
+
+ عرض المزيد
+ عرض أقل
+ قم بتحرير كل إجراء إعلام أدناه من خلال النقر عليه. يتم تعيين الإجراءات الثلاثة الأولى (تشغيل/إيقاف مؤقت، السابق والتالي) بواسطة النظام ولا يمكن تخصيصها.
+ لا توجد مساحة خالية كافية على الجهاز
+ اعادة ضبط الإعداداتِ
+ النسخ الاحتياطيُّ والاستعادة
+ أعيدوا جميع الإعدادات إلى قيمهم الافتراضية
+ ستؤدي إعادة ضبط جميع الإعدادات إلى تجاهل جميع إعداداتك المفضلة وإعادة تشغيل التطبيق.
+\n
+\nهل انت متأكد انك تريد المتابعة؟
+ نعم
+ يمكن لـ NewPipe البحث تلقائيًا عن الإصدارات الجديدة من وقت لآخر وإعلامك بمجرد توفرها.
+\nهل تريد تمكين هذا؟
+ لا
\ No newline at end of file
diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml
index e3de4f4d2..66bfe75de 100644
--- a/app/src/main/res/values-az/strings.xml
+++ b/app/src/main/res/values-az/strings.xml
@@ -448,7 +448,7 @@
- %s video
- %s video
- Yeniləmələri yoxla
+ Yeniləmələri yoxla
Axtarış çubuğunun miniatür önizləməsi
Əməliyyat sistem tərəfindən ləğv edildi
Avto
@@ -548,7 +548,7 @@
İzləniləni sil
Sistem qovluğu seçicisini (SAF) istifadə et
Bağlantı fasiləsi
- Cihazda yer qalmayıb
+ Cihazda yer qalmayıb
Fayl üzərində işləyərkən NewPipe bağlandı
Emaldan sonra uğursuz oldu
Serverə qoşulmaq mümkün deyil
@@ -694,7 +694,7 @@
Bu video yalnız YouTube Music Premium üzvləri üçün əlçatandır, ona görə də NewPipe tərəfindən yayımlamaq və ya endirmək mümkün deyil.
İndi açıqlamadakı mətni seçə bilərsiniz. Nəzərə alın ki, seçim rejimində səhifə titrəyə və linklər kliklənməyə bilər.
Bildirişdə göstərilən video miniatürünü 16:9-dan 1:1 görünüş nisbətinə qədər kəs
- Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq redaktə et. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seç
+ Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq düzəliş edin. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seçin.
Belə fayl/məzmun mənbəyi yoxdur
Seçilən yayım xarici oynadıcılar tərəfindən dəstəklənmir
Yükləyici tərəfindən hələ dəstəklənməyən yayımlar göstərilmir
@@ -769,4 +769,5 @@
Axın yenilənərkən əldə edilən səhifələr.Kanal sürətli rejim istifadə edərək yenilənirsə, bu seçimin heç bir təsiri yoxdur.
Yükləyici avatarları
Miniatürlər
+ Aşağıdakı hər bildirişə vuraraq ona düzəliş edin. İlk üç əməl (oynatma/fasilə, əvvəlki və sonrakı) sistem tərəfindən təyin olunub və dəyişdirilə bilməz.
\ No newline at end of file
diff --git a/app/src/main/res/values-b+ast/strings.xml b/app/src/main/res/values-b+ast/strings.xml
index 626e3f284..5cc516f46 100644
--- a/app/src/main/res/values-b+ast/strings.xml
+++ b/app/src/main/res/values-b+ast/strings.xml
@@ -171,7 +171,7 @@
Yá esiste un ficheru baxáu con esti nome
nun pue sobrecribise\'l ficheru
Hai una descarga pendiente con esti nome
- Nun queda espaciu nel preséu
+ Nun queda espaciu nel preséu
Escosó\'l tiempu d\'espera de la conexón
Nun pudieron importase les soscripciones
Sotítulos
diff --git a/app/src/main/res/values-b+uz+Latn/strings.xml b/app/src/main/res/values-b+uz+Latn/strings.xml
index 0ba69ae41..c3c187891 100644
--- a/app/src/main/res/values-b+uz+Latn/strings.xml
+++ b/app/src/main/res/values-b+uz+Latn/strings.xml
@@ -58,7 +58,7 @@
Kodi bilan ijro etish
Faqat ba\'zi qurilmalar 2K / 4K videolarni ijro etishi mumkin
Yuqori o\'lchamlarni ko\'rsatish
- "Standart pop-up o\'lchamlari"
+ Standart pop-up o\'lchamlari
Standart o\'lchamlari
Audio fayllar uchun yuklab olish papkasini tanlash
Yuklab olingan videofayllar shu yerda saqlanadi
@@ -416,7 +416,7 @@
Ushbu yuklab olishni tiklab bo\'lmaydi
Ulanish vaqti tugadi
Siljish yo\'qoldi, chunki fayl o\'chirildi
- Qurilmada bo\'sh joy qolmadi
+ Qurilmada bo\'sh joy qolmadi
NewPipe fayl ustida ishlash paytida yopilgan
Keyingi ishlov berilmadi
Topilmadi
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index 38905ed7f..dceaaf6c5 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -6,8 +6,8 @@
Патокавы плэер не знойдзены (вы можаце ўсталяваць VLC каб прайграць).
Усталяваць
Скасаваць
- Адкрыць у браўзеры
- Адкрыць у асобным акне
+ Адкрыць ў браўзеры
+ Адкрыць ў асобным акне
Падзяліцца
Спампаваць
Загрузка файла прамой трансляцыі
@@ -37,12 +37,12 @@
Загружаныя аўдыёфайлы захоўваюцца тут
Абярыце тэчку загрузкі для аўдыёфайлаў
Разрознянне па змаўчанні
- Разрозненне усплываючага акна
+ Разрозненне ўсплываючага акна
Высокія разрозненні
Толькі некаторыя прылады могуць прайграваць відэа ў 2K/4K
- Прайграць у Kodi
- Усталяваць адсутную праграму Kore\?
- Паказаць опцыю \"Прайграць у Kodi\"
+ Прайграць ў Kodi
+ Ўсталяваць адсутную праграму Kore?
+ Паказаць опцыю \"Прайграць ў Kodi\"
Паказаць опцыю прайгравання відэа праз медыяцэнтр Kodi
Аўдыё
Фармат аўдыё па змаўчанні
@@ -70,7 +70,7 @@
Узнавіць прайграванне
Працягваць прайграванне пасля перапынкаў (напрыклад, тэлефонных званкоў)
Загрузіць
- \"Наступнае\" и \"Прапанаванае\" відэа
+ \"Наступнае\" і \"Прапанаванае\" відэа
Паказаць падказку \"Утрымлівайце, каб паставіць у чаргу\"
Паказаць падказку пры націсканні фонавай або ўсплывальнай кнопкі ў відэа \"Падрабязнасці:\"
URL не падтрымліваецца
@@ -227,7 +227,7 @@
\nПалітыка прыватнасці NewPipe падрабязна тлумачыць, якія дадзеныя адпраўляюцца і захоўваюцца пры адпраўцы справаздачы аб збоях.