diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 03662d1bcbf..b23bc12207c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -1379,14 +1379,9 @@ class VideoDetailFragment : } if (info.viewCount >= 0) { - binding.detailViewCountView.text = - if (info.streamType == StreamType.AUDIO_LIVE_STREAM) { - Localization.listeningCount(activity, info.viewCount) - } else if (info.streamType == StreamType.LIVE_STREAM) { - Localization.localizeWatchingCount(activity, info.viewCount) - } else { - Localization.localizeViewCount(activity, info.viewCount) - } + binding.detailViewCountView.text = Localization.localizeViewCount( + activity, false, info.streamType, info.viewCount + ) binding.detailViewCountView.visibility = View.VISIBLE } else { binding.detailViewCountView.visibility = View.GONE diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 8a117a47a9a..9aecd487d70 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -2,6 +2,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import android.content.Context; import android.content.SharedPreferences; @@ -22,12 +23,15 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; @@ -256,7 +260,10 @@ protected void initListeners() { infoListAdapter.setOnStreamSelectedListener(new OnClickGesture<>() { @Override public void selected(final StreamInfoItem selectedItem) { - onStreamSelected(selectedItem); + onItemSelected(selectedItem); + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName(), + null, false); } @Override @@ -265,23 +272,50 @@ public void held(final StreamInfoItem selectedItem) { } }); - infoListAdapter.setOnChannelSelectedListener(selectedItem -> { - try { - onItemSelected(selectedItem); - NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), - selectedItem.getUrl(), selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); + infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() { + @Override + public void selected(final ChannelInfoItem selectedItem) { + try { + onItemSelected(selectedItem); + NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(BaseListFragment.this, "Opening channel fragment", + e); + } + } + + @Override + public void held(final ChannelInfoItem selectedItem) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromChannelInfoItem(selectedItem), + LongPressAction.fromChannelInfoItem(selectedItem, null) + ); } }); - infoListAdapter.setOnPlaylistSelectedListener(selectedItem -> { - try { - onItemSelected(selectedItem); - NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(), - selectedItem.getUrl(), selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e); + infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() { + @Override + public void selected(final PlaylistInfoItem selectedItem) { + try { + BaseListFragment.this.onItemSelected(selectedItem); + NavigationHelper.openPlaylistFragment(BaseListFragment.this.getFM(), + selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(BaseListFragment.this, + "Opening playlist fragment", e); + } + } + + @Override + public void held(final PlaylistInfoItem selectedItem) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromPlaylistInfoItem(selectedItem), + LongPressAction.fromPlaylistInfoItem(selectedItem) + ); } }); @@ -291,6 +325,15 @@ public void held(final StreamInfoItem selectedItem) { useNormalItemListScrollListener(); } + protected void showInfoItemDialog(final StreamInfoItem item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamInfoItem(item), + // TODO generalize obtaining queue from here when fully migrating to Compose + LongPressAction.fromStreamInfoItem(item, null) + ); + } + /** * Removes all listeners and adds the normal scroll listener to the {@link #itemsList}. */ @@ -373,27 +416,12 @@ public void onScrolledDown(final RecyclerView recyclerView) { } } - private void onStreamSelected(final StreamInfoItem selectedItem) { - onItemSelected(selectedItem); - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName(), - null, false); - } - protected void onScrollToBottom() { if (hasMoreItems() && !isLoading.get()) { loadMoreItems(); } } - protected void showInfoItemDialog(final StreamInfoItem item) { - try { - new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } - } - /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java index feb23b6ac9f..855b289073c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.fragments.list.channel; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; + import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; @@ -26,12 +28,15 @@ import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.PlayButtonHelper; import java.util.List; +import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -165,13 +170,30 @@ public void handleResult(@NonNull final ChannelTabInfo result) { } @Override - public PlayQueue getPlayQueue() { + protected void showInfoItemDialog(final StreamInfoItem item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamInfoItem(item), + LongPressAction.fromStreamInfoItem(item, () -> getPlayQueueStartingAt(item)) + ); + } + + private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { + return getPlayQueue(streamItems -> Math.max(streamItems.indexOf(infoItem), 0)); + } + + public PlayQueue getPlayQueue(final Function, Integer> index) { final List streamItems = infoListAdapter.getItemsList().stream() .filter(StreamInfoItem.class::isInstance) .map(StreamInfoItem.class::cast) .collect(Collectors.toList()); return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler, - currentInfo.getNextPage(), streamItems, 0); + currentInfo.getNextPage(), streamItems, index.apply(streamItems)); + } + + @Override + public PlayQueue getPlayQueue() { + return getPlayQueue(streamItems -> 0); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index be4f076ddf0..634e2520a47 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -3,9 +3,9 @@ import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; -import android.content.Context; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -42,12 +42,12 @@ import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; @@ -150,21 +150,11 @@ private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { @Override protected void showInfoItemDialog(final StreamInfoItem item) { - final Context context = getContext(); - try { - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, item); - - dialogBuilder - .setAction( - StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, - (f, infoItem) -> NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(infoItem), true)) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } + openLongPressMenuInActivity( + activity, + LongPressable.fromStreamInfoItem(item), + LongPressAction.fromStreamInfoItem(item, () -> getPlayQueueStartingAt(item)) + ); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java deleted file mode 100644 index cbaae2834b8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ /dev/null @@ -1,356 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Build; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -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; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.external_communication.KoreUtils; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; - -/** - * Dialog for a {@link StreamInfoItem}. - * The dialog's content are actions that can be performed on the {@link StreamInfoItem}. - * This dialog is mostly used for longpress context menus. - */ -public final class InfoItemDialog { - private static final String TAG = Build.class.getSimpleName(); - /** - * Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}. - * However, extending {@link AlertDialog} requires many additional lines - * and brings more complexity to this class, especially the constructor. - * To circumvent this, an {@link AlertDialog.Builder} is used in the constructor. - * Its result is stored in this class variable to allow access via the {@link #show()} method. - */ - private final AlertDialog dialog; - - private InfoItemDialog(@NonNull final Activity activity, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem info, - @NonNull final List entries) { - - // Create the dialog's title - final View bannerView = View.inflate(activity, R.layout.dialog_title, null); - bannerView.setSelected(true); - - final TextView titleView = bannerView.findViewById(R.id.itemTitleView); - titleView.setText(info.getName()); - - final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); - if (info.getUploaderName() != null) { - detailsView.setText(info.getUploaderName()); - detailsView.setVisibility(View.VISIBLE); - } else { - detailsView.setVisibility(View.GONE); - } - - // Get the entry's descriptions which are displayed in the dialog - final String[] items = entries.stream() - .map(entry -> entry.getString(activity)).toArray(String[]::new); - - // Call an entry's action / onClick method when the entry is selected. - final DialogInterface.OnClickListener action = (d, index) -> - entries.get(index).action.onClick(fragment, info); - - dialog = new AlertDialog.Builder(activity) - .setCustomTitle(bannerView) - .setItems(items, action) - .create(); - - } - - public void show() { - dialog.show(); - } - - /** - *

Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.

- * Use {@link #addEntry(StreamDialogDefaultEntry)} - * and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog. - *
- * Custom actions for entries can be set using - * {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}. - */ - public static class Builder { - @NonNull private final Activity activity; - @NonNull private final Context context; - @NonNull private final StreamInfoItem infoItem; - @NonNull private final Fragment fragment; - @NonNull private final List entries = new ArrayList<>(); - private final boolean addDefaultEntriesAutomatically; - - /** - *

Create a {@link Builder builder} instance for a {@link StreamInfoItem} - * that automatically adds the some default entries - * at the top and bottom of the dialog.

- * The dialog has the following structure: - *
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | ENQUEUE                                    |
-         *     | ENQUEUE_NEXT                               |
-         *     | START_ON_BACKGROUND                        |
-         *     | START_ON_POPUP                             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | entries added manually with                |
-         *     | addEntry() and addAllEntries()             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | APPEND_PLAYLIST                            |
-         *     | SHARE                                      |
-         *     | OPEN_IN_BROWSER                            |
-         *     | PLAY_WITH_KODI                             |
-         *     | MARK_AS_WATCHED                            |
-         *     | SHOW_CHANNEL_DETAILS                       |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         * 
- * Please note that some entries are not added depending on the user's preferences, - * the item's {@link StreamType} and the current player state. - * - * @param activity - * @param context - * @param fragment - * @param infoItem the item for this dialog; all entries and their actions work with - * this {@link StreamInfoItem} - * @throws IllegalArgumentException if activity, context - * or resources is null - */ - public Builder(final Activity activity, - final Context context, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem infoItem) { - this(activity, context, fragment, infoItem, true); - } - - /** - *

Create an instance of this {@link Builder} for a {@link StreamInfoItem}.

- *

If {@code addDefaultEntriesAutomatically} is set to {@code true}, - * some default entries are added to the top and bottom of the dialog.

- * The dialog has the following structure: - *
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | ENQUEUE                                    |
-         *     | ENQUEUE_NEXT                               |
-         *     | START_ON_BACKGROUND                        |
-         *     | START_ON_POPUP                             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | entries added manually with                |
-         *     | addEntry() and addAllEntries()             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | APPEND_PLAYLIST                            |
-         *     | SHARE                                      |
-         *     | OPEN_IN_BROWSER                            |
-         *     | PLAY_WITH_KODI                             |
-         *     | MARK_AS_WATCHED                            |
-         *     | SHOW_CHANNEL_DETAILS                       |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         * 
- * Please note that some entries are not added depending on the user's preferences, - * the item's {@link StreamType} and the current player state. - * - * @param activity - * @param context - * @param fragment - * @param infoItem - * @param addDefaultEntriesAutomatically - * whether default entries added with {@link #addDefaultBeginningEntries()} - * and {@link #addDefaultEndEntries()} are added automatically when generating - * the {@link InfoItemDialog}. - *
- * Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and - * {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between. - * @throws IllegalArgumentException if activity, context - * or resources is null - */ - public Builder(final Activity activity, - final Context context, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem infoItem, - final boolean addDefaultEntriesAutomatically) { - if (activity == null || context == null || context.getResources() == null) { - if (DEBUG) { - Log.d(TAG, "activity, context or resources is null: activity = " - + activity + ", context = " + context); - } - throw new IllegalArgumentException("activity, context or resources is null"); - } - this.activity = activity; - this.context = context; - this.fragment = fragment; - this.infoItem = infoItem; - this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically; - if (addDefaultEntriesAutomatically) { - addDefaultBeginningEntries(); - } - } - - /** - * Adds a new entry and appends it to the current entry list. - * @param entry the entry to add - * @return the current {@link Builder} instance - */ - public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) { - entries.add(entry.toStreamDialogEntry()); - return this; - } - - /** - * Adds new entries. These are appended to the current entry list. - * @param newEntries the entries to add - * @return the current {@link Builder} instance - */ - public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) { - Stream.of(newEntries).forEach(this::addEntry); - return this; - } - - /** - *

Change an entries' action that is called when the entry is selected.

- *

Warning: Only use this method when the entry has been already added. - * Changing the action of an entry which has not been added to the Builder yet - * does not have an effect.

- * @param entry the entry to change - * @param action the action to perform when the entry is selected - * @return the current {@link Builder} instance - */ - public Builder setAction(@NonNull final StreamDialogDefaultEntry entry, - @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { - for (int i = 0; i < entries.size(); i++) { - if (entries.get(i).resource == entry.resource) { - entries.set(i, new StreamDialogEntry(entry.resource, action)); - return this; - } - } - return this; - } - - /** - * Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and - * {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams - * in the play queue. - * @return the current {@link Builder} instance - */ - public Builder addEnqueueEntriesIfNeeded() { - final PlayerHolder holder = PlayerHolder.INSTANCE; - if (holder.isPlayQueueReady()) { - addEntry(StreamDialogDefaultEntry.ENQUEUE); - - if (holder.getQueuePosition() < holder.getQueueSize() - 1) { - addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); - } - } - return this; - } - - /** - * Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}. - * If the {@link #infoItem} is not a pure audio (live) stream, - * {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too. - * @return the current {@link Builder} instance - */ - public Builder addStartHereEntries() { - addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); - if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { - addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); - } - return this; - } - - /** - * Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled - * and the stream is not a livestream. - * @return the current {@link Builder} instance - */ - public Builder addMarkAsWatchedEntryIfNeeded() { - final boolean isWatchHistoryEnabled = PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.enable_watch_history_key), false); - if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) { - addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); - } - return this; - } - - /** - * Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed. - * @return the current {@link Builder} instance - */ - public Builder addPlayWithKodiEntryIfNeeded() { - if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { - addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI); - } - return this; - } - - /** - * Add the entries which are usually at the top of the action list. - *
- * This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()}) - * and "start here" (see {@link #addStartHereEntries()} entries. - * @return the current {@link Builder} instance - */ - public Builder addDefaultBeginningEntries() { - addEnqueueEntriesIfNeeded(); - addStartHereEntries(); - return this; - } - - /** - * Add the entries which are usually at the bottom of the action list. - * @return the current {@link Builder} instance - */ - public Builder addDefaultEndEntries() { - addAllEntries( - StreamDialogDefaultEntry.DOWNLOAD, - StreamDialogDefaultEntry.APPEND_PLAYLIST, - StreamDialogDefaultEntry.SHARE, - StreamDialogDefaultEntry.OPEN_IN_BROWSER - ); - addPlayWithKodiEntryIfNeeded(); - addMarkAsWatchedEntryIfNeeded(); - addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS); - return this; - } - - /** - * Creates the {@link InfoItemDialog}. - * @return a new instance of {@link InfoItemDialog} - */ - public InfoItemDialog create() { - if (addDefaultEntriesAutomatically) { - addDefaultEndEntries(); - } - return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries); - } - - public static void reportErrorDuringInitialization(final Throwable throwable, - final InfoItem item) { - ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo( - throwable, - UserAction.OPEN_INFO_ITEM_DIALOG, - "none", - item.getServiceId())); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java deleted file mode 100644 index ff3b2478e6e..00000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment; -import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse; -import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; -import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse; - -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; - -/** - *

- * This enum provides entries that are accepted - * by the {@link InfoItemDialog.Builder}. - *

- *

- * These entries contain a String {@link #resource} which is displayed in the dialog and - * a default {@link #action} that is executed - * when the entry is selected (via onClick()). - *
- * They action can be overridden by using the Builder's - * {@link InfoItemDialog.Builder#setAction( - * StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)} - * method. - *

- */ -public enum StreamDialogDefaultEntry { - SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> { - final var activity = fragment.requireActivity(); - fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(), - item.getUploaderUrl(), url -> openChannelFragment(activity, item, url)); - }), - - /** - * Enqueues the stream automatically to the current PlayerType. - */ - ENQUEUE(R.string.enqueue_stream, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue)) - ), - - /** - * Enqueues the stream automatically to the current PlayerType - * after the currently playing stream. - */ - ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue)) - ), - - START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.playOnBackgroundPlayer( - fragment.getContext(), singlePlayQueue, true))), - - START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))), - - SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> { - throw new UnsupportedOperationException("This needs to be implemented manually " - + "by using InfoItemDialog.Builder.setAction()"); - }), - - DELETE(R.string.delete, (fragment, item) -> { - throw new UnsupportedOperationException("This needs to be implemented manually " - + "by using InfoItemDialog.Builder.setAction()"); - }), - - /** - * Opens a {@link PlaylistDialog} to either append the stream to a playlist - * or create a new playlist if there are no local playlists. - */ - APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) -> - PlaylistDialog.createCorrespondingDialog( - fragment.getContext(), - List.of(new StreamEntity(item)), - dialog -> dialog.show( - fragment.getParentFragmentManager(), - "StreamDialogEntry@" - + (dialog instanceof PlaylistAppendDialog ? "append" : "create") - + "_playlist" - ) - ) - ), - - PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> - KoreUtils.playWithKore(fragment.requireContext(), Uri.parse(item.getUrl()))), - - SHARE(R.string.share, (fragment, item) -> - ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), - item.getThumbnails())), - - /** - * Opens a {@link DownloadDialog} after fetching some stream info. - * If the user quits the current fragment, it will not open a DownloadDialog. - */ - DOWNLOAD(R.string.download, (fragment, item) -> - fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(), - item.getUrl(), info -> { - // Ensure the fragment is attached and its state hasn't been saved to avoid - // showing dialog during lifecycle changes or when the activity is paused, - // e.g. by selecting the download option and opening a different fragment. - if (fragment.isAdded() && !fragment.isStateSaved()) { - final DownloadDialog downloadDialog = - new DownloadDialog(fragment.requireContext(), info); - downloadDialog.show(fragment.getChildFragmentManager(), - "downloadDialog"); - } - }) - ), - - OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) -> - ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())), - - - MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) -> - new HistoryRecordManager(fragment.getContext()) - .markAsWatched(item) - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe() - ); - - - @StringRes - public final int resource; - @NonNull - public final StreamDialogEntry.StreamDialogEntryAction action; - - StreamDialogDefaultEntry(@StringRes final int resource, - @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { - this.resource = resource; - this.action = action; - } - - @NonNull - public StreamDialogEntry toStreamDialogEntry() { - return new StreamDialogEntry(resource, action); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java deleted file mode 100644 index 9d82e3b5829..00000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.extractor.stream.StreamInfoItem; - -public class StreamDialogEntry { - - @StringRes - public final int resource; - @NonNull - public final StreamDialogEntryAction action; - - public StreamDialogEntry(@StringRes final int resource, - @NonNull final StreamDialogEntryAction action) { - this.resource = resource; - this.action = action; - } - - public String getString(@NonNull final Context context) { - return context.getString(resource); - } - - public interface StreamDialogEntryAction { - void onClick(Fragment fragment, StreamInfoItem infoItem); - } -} 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 80f62eed3d1..84ee2742a89 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 @@ -7,7 +7,6 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; @@ -65,16 +64,8 @@ public void updateFromItem(final InfoItem infoItem, private String getStreamInfoDetailLine(final StreamInfoItem infoItem) { String viewsAndDate = ""; if (infoItem.getViewCount() >= 0) { - if (infoItem.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { - viewsAndDate = Localization - .listeningCount(itemBuilder.getContext(), infoItem.getViewCount()); - } else if (infoItem.getStreamType().equals(StreamType.LIVE_STREAM)) { - viewsAndDate = Localization - .shortWatchingCount(itemBuilder.getContext(), infoItem.getViewCount()); - } else { - viewsAndDate = Localization - .shortViewCount(itemBuilder.getContext(), infoItem.getViewCount()); - } + viewsAndDate = Localization.localizeViewCount(itemBuilder.getContext(), true, + infoItem.getStreamType(), infoItem.getViewCount()); } final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(), diff --git a/app/src/main/java/org/schabi/newpipe/ktx/List.kt b/app/src/main/java/org/schabi/newpipe/ktx/List.kt new file mode 100644 index 00000000000..0dd41bb6e1f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/List.kt @@ -0,0 +1,9 @@ +package org.schabi.newpipe.ktx + +fun MutableList.popFirst(filter: (A) -> Boolean): A? { + val i = indexOfFirst(filter) + if (i < 0) { + return null + } + return removeAt(i) +} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java index 49962533257..c36fa8549a8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java @@ -1,8 +1,8 @@ package org.schabi.newpipe.local.bookmark; import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; -import android.content.DialogInterface; import android.os.Bundle; import android.os.Parcelable; import android.text.InputType; @@ -39,6 +39,8 @@ import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.ui.emptystate.EmptyStateSpec; import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import org.schabi.newpipe.util.NavigationHelper; @@ -162,7 +164,7 @@ public void held(final LocalItem selectedItem) { if (selectedItem instanceof PlaylistMetadataEntry) { showLocalDialog((PlaylistMetadataEntry) selectedItem); } else if (selectedItem instanceof PlaylistRemoteEntity) { - showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem); + showRemoteDialog((PlaylistRemoteEntity) selectedItem); } } @@ -491,42 +493,31 @@ public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, // Utils /////////////////////////////////////////////////////////////////////////// - private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { - showDeleteDialog(item.getName(), item); + private void showRemoteDialog(final PlaylistRemoteEntity item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromPlaylistRemoteEntity(item), + LongPressAction.fromPlaylistRemoteEntity( + item, + () -> showDeleteDialog(item.getName(), item) + ) + ); } private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { - final String rename = getString(R.string.rename); - final String delete = getString(R.string.delete); - final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail); final boolean isThumbnailPermanent = localPlaylistManager .getIsPlaylistThumbnailPermanent(selectedItem.getUid()); - final ArrayList items = new ArrayList<>(); - items.add(rename); - items.add(delete); - if (isThumbnailPermanent) { - items.add(unsetThumbnail); - } - - final DialogInterface.OnClickListener action = (d, index) -> { - if (items.get(index).equals(rename)) { - showRenameDialog(selectedItem); - } else if (items.get(index).equals(delete)) { - showDeleteDialog(selectedItem.name, selectedItem); - } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { - final long thumbnailStreamId = localPlaylistManager - .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()); - localPlaylistManager - .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(); - } - }; - - new AlertDialog.Builder(activity) - .setItems(items.toArray(new String[0]), action) - .show(); + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromPlaylistMetadataEntry(selectedItem), + LongPressAction.fromPlaylistMetadataEntry( + selectedItem, + () -> showRenameDialog(selectedItem), + () -> showDeleteDialog(selectedItem.name, selectedItem), + isThumbnailPermanent ? () -> unsetPermanentThumbnail(selectedItem) : null + ) + ); } private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { @@ -559,4 +550,13 @@ private void showDeleteDialog(final String name, final PlaylistLocalItem item) { .setNegativeButton(R.string.cancel, null) .show(); } + + private void unsetPermanentThumbnail(final PlaylistMetadataEntry item) { + final long thumbnailStreamId = localPlaylistManager + .getAutomaticPlaylistThumbnailStreamId(item.getUid()); + localPlaylistManager + .changePlaylistThumbnail(item.getUid(), thumbnailStreamId, false) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(); + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index 038f2bed19c..2eea8b56d5e 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -20,7 +20,6 @@ package org.schabi.newpipe.local.feed import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -63,17 +62,19 @@ import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException -import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.info_list.ItemViewMode -import org.schabi.newpipe.info_list.dialog.InfoItemDialog import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling import org.schabi.newpipe.ktx.slideUp import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedLoadService import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.ui.components.menu.LongPressAction +import org.schabi.newpipe.ui.components.menu.LongPressable +import org.schabi.newpipe.ui.components.menu.openLongPressMenuInActivity import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.Localization @@ -379,18 +380,10 @@ class FeedFragment : BaseStateFragment() { feedBinding.loadingProgressBar.max = progressState.maxProgress } - private fun showInfoItemDialog(item: StreamInfoItem) { - val context = context - val activity: Activity? = getActivity() - if (context == null || context.resources == null || activity == null) return - - InfoItemDialog.Builder(activity, context, this, item).create().show() - } - private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { override fun onItemClick(item: Item<*>, view: View) { if (item is StreamItem && !isRefreshing) { - val stream = item.streamWithState.stream + val stream = item.stream NavigationHelper.openVideoDetailFragment( requireContext(), fm, stream.serviceId, stream.url, stream.title, null, false @@ -400,7 +393,34 @@ class FeedFragment : BaseStateFragment() { override fun onItemLongClick(item: Item<*>, view: View): Boolean { if (item is StreamItem && !isRefreshing) { - showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem()) + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamEntity(item.stream), + LongPressAction.fromStreamEntity( + item = item.stream, + queueFromHere = { + val items = (viewModel.stateLiveData.value as? FeedState.LoadedState) + ?.items + + if (items != null) { + val index = items.indexOf(item) + if (index >= 0) { + return@fromStreamEntity SinglePlayQueue( + items.map { it.stream.toStreamInfoItem() }, + index + ) + } + } + + // when long-pressing on an item the state should be LoadedState and the + // item list should contain the long-pressed item, so the following + // statement should be unreachable, but let's return a SinglePlayQueue + // just in case + Log.w(TAG, "Could not get full list of items on long press") + return@fromStreamEntity SinglePlayQueue(item.stream.toStreamInfoItem()) + }, + ), + ) return true } return false @@ -569,7 +589,7 @@ class FeedFragment : BaseStateFragment() { } if (doCheck) { // If the uploadDate is null or true we should highlight the item - if (item.streamWithState.stream.uploadDate?.isAfter(updateTime) != false) { + if (item.stream.uploadDate?.isAfter(updateTime) != false) { highlightCount++ typeface = Typeface.DEFAULT_BOLD diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 030bb7a7668..8be1659cace 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -1,7 +1,6 @@ package org.schabi.newpipe.local.feed.item import android.content.Context -import android.text.TextUtils import android.view.View import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager @@ -31,7 +30,7 @@ data class StreamItem( const val UPDATE_RELATIVE_TIME = 1 } - private val stream: StreamEntity = streamWithState.stream + val stream: StreamEntity = streamWithState.stream private val stateProgressTime: Long? = streamWithState.stateProgressMillis /** @@ -117,22 +116,16 @@ data class StreamItem( } private fun getStreamInfoDetailLine(context: Context): String { - var viewsAndDate = "" - val viewCount = stream.viewCount - if (viewCount != null && viewCount >= 0) { - viewsAndDate = when (stream.streamType) { - AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount) - LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount) - else -> Localization.shortViewCount(context, viewCount) - } - } + val views = stream.viewCount + ?.takeIf { it >= 0 } + ?.let { Localization.localizeViewCount(context, true, stream.streamType, it) } + ?: "" + val uploadDate = getFormattedRelativeUploadDate(context) return when { - !TextUtils.isEmpty(uploadDate) -> when { - viewsAndDate.isEmpty() -> uploadDate!! - else -> Localization.concatenateStrings(viewsAndDate, uploadDate) - } - else -> viewsAndDate + uploadDate.isNullOrEmpty() -> views + views.isEmpty() -> uploadDate + else -> Localization.concatenateStrings(views, uploadDate) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java index 709a16b68b6..e7e0b7416e8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java @@ -55,7 +55,7 @@ protected String getFormattedDate(final Date date) { } protected String getFormattedViewString(final long viewCount) { - return Localization.shortViewCount(mContext, viewCount); + return Localization.localizeWatchCount(mContext, viewCount); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 3302e387ec5..af4e8d1088c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.local.history; -import android.content.Context; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; + import android.os.Bundle; import android.os.Parcelable; import android.view.LayoutInflater; @@ -9,14 +10,12 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.viewbinding.ViewBinding; import com.evernote.android.state.State; -import com.google.android.material.snackbar.Snackbar; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -30,12 +29,12 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.PlayButtonHelper; @@ -48,7 +47,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; public class StatisticsPlaylistFragment extends BaseLocalListFragment, Void> @@ -318,50 +316,11 @@ private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) { } private void showInfoItemDialog(final StreamStatisticsEntry item) { - final Context context = getContext(); - final StreamInfoItem infoItem = item.toStreamInfoItem(); - - try { - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - - // set entries in the middle; the others are added automatically - dialogBuilder - .addEntry(StreamDialogDefaultEntry.DELETE) - .setAction( - StreamDialogDefaultEntry.DELETE, - (f, i) -> deleteEntry( - Math.max(itemListAdapter.getItemsList().indexOf(item), 0))) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); - } - } - - private void deleteEntry(final int index) { - final LocalItem infoItem = itemListAdapter.getItemsList().get(index); - if (infoItem instanceof StreamStatisticsEntry) { - final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem; - final Disposable onDelete = recordManager - .deleteStreamHistoryAndState(entry.getStreamId()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - () -> { - if (getView() != null) { - Snackbar.make(getView(), R.string.one_item_deleted, - Snackbar.LENGTH_SHORT).show(); - } else { - Toast.makeText(getContext(), - R.string.one_item_deleted, - Toast.LENGTH_SHORT).show(); - } - }, - throwable -> showSnackBarError(new ErrorInfo(throwable, - UserAction.DELETE_FROM_HISTORY, "Deleting item"))); - - disposables.add(onDelete); - } + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamEntity(item.getStreamEntity()), + LongPressAction.fromStreamStatisticsEntry(item, () -> getPlayQueueStartingAt(item)) + ); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index f26a76ad9f7..dd8edfa66f4 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -73,7 +73,7 @@ private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, final DateTimeFormatter dateTimeFormatter) { return Localization.concatenateStrings( // watchCount - Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()), + Localization.localizeWatchCount(itemBuilder.getContext(), entry.getWatchCount()), dateTimeFormatter.format(entry.getLatestAccessDate()), // serviceName ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId())); 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 f5562549cf5..6b7da26a9aa 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 @@ -6,6 +6,7 @@ import static org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS; import static org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES; import static org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; @@ -49,12 +50,12 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; @@ -797,39 +798,16 @@ private PlayQueue getPlayQueueStartingAt(final PlaylistStreamEntry infoItem) { } protected void showInfoItemDialog(final PlaylistStreamEntry item) { - final StreamInfoItem infoItem = item.toStreamInfoItem(); - - try { - final Context context = getContext(); - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - - // add entries in the middle - dialogBuilder.addAllEntries( - StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, - StreamDialogDefaultEntry.DELETE - ); - - // set custom actions - // all entries modified below have already been added within the builder - dialogBuilder - .setAction( - StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, - (f, i) -> NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(item), true)) - .setAction( - StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, - (f, i) -> - changeThumbnailStreamId(item.getStreamEntity().getUid(), - true)) - .setAction( - StreamDialogDefaultEntry.DELETE, - (f, i) -> deleteItem(item)) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); - } + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamEntity(item.getStreamEntity()), + LongPressAction.fromPlaylistStreamEntry( + item, + () -> getPlayQueueStartingAt(item), + () -> deleteItem(item), + () -> changeThumbnailStreamId(item.getStreamEntity().getUid(), true) + ) + ); } private void setInitialData(final long pid, final String title) { diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 8c5d05394d1..fff67074b64 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -2,7 +2,6 @@ package org.schabi.newpipe.local.subscription import android.app.Activity import android.content.Context -import android.content.DialogInterface import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -17,7 +16,6 @@ import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import com.evernote.android.state.State @@ -28,7 +26,6 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder import io.reactivex.rxjava3.disposables.CompositeDisposable import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID -import org.schabi.newpipe.databinding.DialogTitleBinding import org.schabi.newpipe.databinding.FeedItemCarouselBinding import org.schabi.newpipe.databinding.FragmentSubscriptionBinding import org.schabi.newpipe.error.ErrorInfo @@ -53,12 +50,14 @@ import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.ui.components.menu.LongPressAction +import org.schabi.newpipe.ui.components.menu.LongPressable +import org.schabi.newpipe.ui.components.menu.openLongPressMenuInActivity import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels -import org.schabi.newpipe.util.external_communication.ShareUtils import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -329,31 +328,14 @@ class SubscriptionFragment : BaseStateFragment() { } private fun showLongTapDialog(selectedItem: ChannelInfoItem) { - val commands = arrayOf( - getString(R.string.share), - getString(R.string.open_in_browser), - getString(R.string.unsubscribe) + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromChannelInfoItem(selectedItem), + LongPressAction.fromChannelInfoItem( + item = selectedItem, + onUnsubscribe = { deleteChannel(selectedItem) } + ) ) - - val actions = DialogInterface.OnClickListener { _, i -> - when (i) { - 0 -> ShareUtils.shareText( - requireContext(), selectedItem.name, selectedItem.url, selectedItem.thumbnails - ) - 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) - 2 -> deleteChannel(selectedItem) - } - } - - val dialogTitleBinding = DialogTitleBinding.inflate(LayoutInflater.from(requireContext())) - dialogTitleBinding.root.isSelected = true - dialogTitleBinding.itemTitleView.text = selectedItem.name - dialogTitleBinding.itemAdditionalDetails.visibility = View.GONE - - AlertDialog.Builder(requireContext()) - .setCustomTitle(dialogTitleBinding.root) - .setItems(commands, actions) - .show() } private fun deleteChannel(selectedItem: ChannelInfoItem) { diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index b8f07fd7142..abcc50b7ab4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -148,8 +148,8 @@ class PlayerService : MediaBrowserServiceCompat() { // a (dummy) foreground notification, otherwise we'd incur in // "Context.startForegroundService() did not then call Service.startForeground()". Then // we stop the service again. - Log.d(TAG, "onStartCommand() got a useless intent, closing the service"); - NotificationUtil.startForegroundWithDummyNotification(this); + Log.d(TAG, "onStartCommand() got a useless intent, closing the service") + NotificationUtil.startForegroundWithDummyNotification(this) return START_NOT_STICKY } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java index a9eb2a19c7e..77b253283d0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java @@ -1,10 +1,14 @@ package org.schabi.newpipe.player.playqueue; +import androidx.annotation.Nullable; + import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.ExtractorHelper; import java.util.Collections; @@ -15,7 +19,8 @@ public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { - final ListLinkHandler linkHandler; + @Nullable + ListLinkHandler linkHandler; public ChannelTabPlayQueue(final int serviceId, final ListLinkHandler linkHandler, @@ -31,6 +36,13 @@ public ChannelTabPlayQueue(final int serviceId, this(serviceId, linkHandler, null, Collections.emptyList(), 0); } + // Plays the first + public ChannelTabPlayQueue(final int serviceId, + final String channelUrl) { + super(serviceId, channelUrl, null, Collections.emptyList(), 0); + linkHandler = null; + } + @Override protected String getTag() { return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode()); @@ -39,10 +51,29 @@ protected String getTag() { @Override public void fetch() { if (isInitial) { - ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHeadListObserver()); + if (linkHandler == null) { + ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false) + .flatMap(channelInfo -> { + linkHandler = channelInfo.getTabs() + .stream() + .filter(ChannelTabHelper::isStreamsTab) + .findFirst() + .orElseThrow(() -> new ExtractionException( + "No playable channel tab found")); + + return ExtractorHelper + .getChannelTab(this.serviceId, this.linkHandler, false); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + + } else { + ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + } } else { ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage) .subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java index 32316f3936d..ee87a64f36c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java @@ -5,6 +5,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; +import java.util.Collections; import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -28,6 +29,11 @@ public PlaylistPlayQueue(final int serviceId, super(serviceId, url, nextPage, streams, index); } + public PlaylistPlayQueue(final int serviceId, + final String url) { + this(serviceId, url, null, Collections.emptyList(), 0); + } + @Override protected String getTag() { return "PlaylistPlayQueue@" + Integer.toHexString(hashCode()); diff --git a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt new file mode 100644 index 00000000000..1a5c6e5dcb7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt @@ -0,0 +1,51 @@ +package org.schabi.newpipe.ui + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.IntOffset + +/** + * Detects a drag gesture **without** trying to filter out any misclicks. This is useful in menus + * where items are dragged around, where the usual misclick guardrails would cause unexpected lags + * or strange behaviors when dragging stuff around quickly. For other use cases, use + * [androidx.compose.foundation.gestures.detectDragGestures] or + * [androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress]. + * + * @param beginDragGesture called when the user first touches the screen (down event) with the + * pointer position. + * @param handleDragGestureChange called with the current pointer position and the difference from + * the last position, every time the user moves the finger after [beginDragGesture] has been called. + * @param endDragGesture called when the drag gesture finishes, after [beginDragGesture] has been + * called. + */ +fun Modifier.detectDragGestures( + beginDragGesture: (position: IntOffset) -> Unit, + handleDragGestureChange: (position: IntOffset, positionChange: Offset) -> Unit, + endDragGesture: () -> Unit +): Modifier { + return this.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val pointerId = down.id + beginDragGesture(down.position.toIntOffset()) + while (true) { + val change = awaitPointerEvent().changes.find { it.id == pointerId } + if (change == null || !change.pressed) { + break + } + handleDragGestureChange( + change.position.toIntOffset(), + change.positionChange(), + ) + change.consume() + } + endDragGesture() + } + } +} + +private fun Offset.toIntOffset() = IntOffset(this.x.toInt(), this.y.toInt()) diff --git a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt index de0c9754035..40a1458af7c 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt @@ -37,8 +37,9 @@ fun TextAction(text: String, modifier: Modifier = Modifier) { @Composable fun NavigationIcon() { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", - modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall) + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall), ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt index 18139c7a68f..f5b8913c97f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt @@ -14,7 +14,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import org.schabi.newpipe.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -40,7 +42,7 @@ fun ScaffoldWithToolbar( IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null + contentDescription = stringResource(R.string.back), ) } }, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt index 4562e17aff7..b2c9bf95a0d 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt @@ -5,10 +5,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -50,20 +47,6 @@ fun ItemList( } } - // Handle long clicks for stream items - // TODO: Adjust the menu display depending on where it was triggered - var selectedStream by remember { mutableStateOf(null) } - val onLongClick = remember { - { stream: StreamInfoItem -> - selectedStream = stream - } - } - val onDismissPopup = remember { - { - selectedStream = null - } - } - val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context) val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()) @@ -80,10 +63,7 @@ fun ItemList( val item = items[it] if (item is StreamInfoItem) { - val isSelected = selectedStream == item - StreamListItem( - item, showProgress, isSelected, onClick, onLongClick, onDismissPopup - ) + StreamListItem(item, showProgress, onClick) } else if (item is PlaylistInfoItem) { PlaylistListItem(item, onClick) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt index 84fff3e74cf..3078a4aff12 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt @@ -10,10 +10,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -21,22 +26,26 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.components.menu.LongPressAction +import org.schabi.newpipe.ui.components.menu.LongPressMenu +import org.schabi.newpipe.ui.components.menu.LongPressable import org.schabi.newpipe.ui.theme.AppTheme -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun StreamListItem( stream: StreamInfoItem, showProgress: Boolean, - isSelected: Boolean, onClick: (StreamInfoItem) -> Unit = {}, - onLongClick: (StreamInfoItem) -> Unit = {}, - onDismissPopup: () -> Unit = {} ) { - // Box serves as an anchor for the dropdown menu + var showLongPressMenu by rememberSaveable { mutableStateOf(false) } + Box( modifier = Modifier - .combinedClickable(onLongClick = { onLongClick(stream) }, onClick = { onClick(stream) }) + .combinedClickable( + onLongClick = { showLongPressMenu = true }, + onClick = { onClick(stream) } + ) .fillMaxWidth() .padding(12.dp) ) { @@ -67,7 +76,14 @@ fun StreamListItem( } } - StreamMenu(stream, isSelected, onDismissPopup) + if (showLongPressMenu) { + LongPressMenu( + longPressable = LongPressable.fromStreamInfoItem(stream), + // TODO queueFromHere: allow playing the whole list starting from one stream + longPressActions = LongPressAction.fromStreamInfoItem(stream, null), + onDismissRequest = { showLongPressMenu = false }, + ) + } } } @@ -79,7 +95,7 @@ private fun StreamListItemPreview( ) { AppTheme { Surface { - StreamListItem(stream, showProgress = false, isSelected = false) + StreamListItem(stream, showProgress = false) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt deleted file mode 100644 index 7619515e71d..00000000000 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ /dev/null @@ -1,137 +0,0 @@ -package org.schabi.newpipe.ui.components.items.stream - -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.viewmodel.compose.viewModel -import org.schabi.newpipe.R -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.download.DownloadDialog -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.ktx.findFragmentActivity -import org.schabi.newpipe.local.dialog.PlaylistAppendDialog -import org.schabi.newpipe.local.dialog.PlaylistDialog -import org.schabi.newpipe.player.helper.PlayerHolder -import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.SparseItemUtil -import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.viewmodels.StreamViewModel - -@Composable -fun StreamMenu( - stream: StreamInfoItem, - expanded: Boolean, - onDismissRequest: () -> Unit -) { - val context = LocalContext.current - val streamViewModel = viewModel() - - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - if (PlayerHolder.isPlayQueueReady) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.enqueue_stream)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.enqueueOnPlayer(context, it) - } - } - ) - - if (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.enqueue_next_stream)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.enqueueNextOnPlayer(context, it) - } - } - ) - } - } - - DropdownMenuItem( - text = { Text(text = stringResource(R.string.start_here_on_background)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.playOnBackgroundPlayer(context, it, true) - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.start_here_on_popup)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.playOnPopupPlayer(context, it, true) - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchStreamInfoAndSaveToDatabase( - context, stream.serviceId, stream.url - ) { info -> - // TODO: Use an AlertDialog composable instead. - val downloadDialog = DownloadDialog(context, info) - val fragmentManager = context.findFragmentActivity().supportFragmentManager - downloadDialog.show(fragmentManager, "downloadDialog") - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.add_to_playlist)) }, - onClick = { - onDismissRequest() - val list = listOf(StreamEntity(stream)) - PlaylistDialog.createCorrespondingDialog(context, list) { dialog -> - val tag = if (dialog is PlaylistAppendDialog) "append" else "create" - dialog.show( - context.findFragmentActivity().supportFragmentManager, - "StreamDialogEntry@${tag}_playlist" - ) - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.share)) }, - onClick = { - onDismissRequest() - ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails) - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.open_in_browser)) }, - onClick = { - onDismissRequest() - ShareUtils.openUrlInBrowser(context, stream.url) - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.mark_as_watched)) }, - onClick = { - onDismissRequest() - streamViewModel.markAsWatched(stream) - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.show_channel_details)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchUploaderUrlIfSparse( - context, stream.serviceId, stream.url, stream.uploaderUrl - ) { url -> - val activity = context.findFragmentActivity() - NavigationHelper.openChannelFragment(activity, stream, url) - } - } - ) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt index cdfe613edf3..eae1bc2e524 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt @@ -36,16 +36,10 @@ internal fun getStreamInfoDetail(stream: StreamInfoItem): String { val context = LocalContext.current return rememberSaveable(stream) { - val count = stream.viewCount - val views = if (count >= 0) { - when (stream.streamType) { - StreamType.AUDIO_LIVE_STREAM -> Localization.listeningCount(context, count) - StreamType.LIVE_STREAM -> Localization.shortWatchingCount(context, count) - else -> Localization.shortViewCount(context, count) - } - } else { - "" - } + val views = stream.viewCount + .takeIf { it >= 0 } + ?.let { Localization.localizeViewCount(context, true, stream.streamType, it) } + ?: "" val date = Localization.relativeTimeOrTextual(context, stream.uploadDate, stream.textualUploadDate) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt new file mode 100644 index 00000000000..45473d2324e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt @@ -0,0 +1,324 @@ +package org.schabi.newpipe.ui.components.menu + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.AddToQueue +import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material.icons.filled.HideImage +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PictureInPicture +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.QueuePlayNext +import androidx.compose.material.icons.filled.Share +import androidx.compose.ui.graphics.vector.ImageVector +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import org.schabi.newpipe.R +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.database.stream.StreamStatisticsEntry +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ktx.findFragmentActivity +import org.schabi.newpipe.local.dialog.PlaylistAppendDialog +import org.schabi.newpipe.local.dialog.PlaylistDialog +import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.ui.components.menu.icons.BackgroundFromHere +import org.schabi.newpipe.ui.components.menu.icons.PlayFromHere +import org.schabi.newpipe.ui.components.menu.icons.PopupFromHere +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.SparseItemUtil +import org.schabi.newpipe.util.external_communication.ShareUtils + +data class LongPressAction( + val type: Type, + val action: (context: Context) -> Unit, + val enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, +) { + enum class Type( + @StringRes val label: Int, + val icon: ImageVector, + ) { + Enqueue(R.string.enqueue, Icons.Default.AddToQueue), + EnqueueNext(R.string.enqueue_next_stream, Icons.Default.QueuePlayNext), + Background(R.string.controls_background_title, Icons.Default.Headset), + Popup(R.string.controls_popup_title, Icons.Default.PictureInPicture), + Play(R.string.play, Icons.Default.PlayArrow), + BackgroundFromHere(R.string.background_from_here, Icons.Default.BackgroundFromHere), + PopupFromHere(R.string.popup_from_here, Icons.Default.PopupFromHere), + PlayFromHere(R.string.play_from_here, Icons.Default.PlayFromHere), + PlayWithKodi(R.string.play_with_kodi_title, Icons.Default.Cast), + Download(R.string.download, Icons.Default.Download), + AddToPlaylist(R.string.add_to_playlist, Icons.AutoMirrored.Default.PlaylistAdd), + Share(R.string.share, Icons.Default.Share), + OpenInBrowser(R.string.open_in_browser, Icons.Default.OpenInBrowser), + ShowChannelDetails(R.string.show_channel_details, Icons.Default.Person), + MarkAsWatched(R.string.mark_as_watched, Icons.Default.Done), + Delete(R.string.delete, Icons.Default.Delete), + Rename(R.string.rename, Icons.Default.Edit), + SetAsPlaylistThumbnail(R.string.set_as_playlist_thumbnail, Icons.Default.Image), + UnsetPlaylistThumbnail(R.string.unset_playlist_thumbnail, Icons.Default.HideImage), + Unsubscribe(R.string.unsubscribe, Icons.Default.Delete), + ; + + // TODO allow actions to return disposables + // TODO add actions that use the whole list the item belongs to (see wholeListQueue) + + fun buildAction( + enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, + action: (context: Context) -> Unit, + ) = LongPressAction(this, action, enabled) + + companion object { + // ShowChannelDetails is not enabled by default, since navigating to channel details can + // also be done by clicking on the uploader name in the long press menu header + val DefaultEnabledActions: Array = arrayOf( + Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, + AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Delete, + Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe + ) + } + } + + companion object { + private fun buildPlayerActionList(queue: () -> PlayQueue): List { + return listOf( + Type.Enqueue.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> + NavigationHelper.enqueueOnPlayer(context, queue()) + }, + Type.EnqueueNext.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> + NavigationHelper.enqueueNextOnPlayer(context, queue()) + }, + Type.Background.buildAction { context -> + NavigationHelper.playOnBackgroundPlayer(context, queue(), true) + }, + Type.Popup.buildAction { context -> + NavigationHelper.playOnPopupPlayer(context, queue(), true) + }, + Type.Play.buildAction { context -> + NavigationHelper.playOnMainPlayer(context, queue(), false) + }, + ) + } + + private fun buildPlayerFromHereActionList(queueFromHere: () -> PlayQueue): List { + return listOf( + Type.BackgroundFromHere.buildAction { context -> + NavigationHelper.playOnBackgroundPlayer(context, queueFromHere(), true) + }, + Type.PopupFromHere.buildAction { context -> + NavigationHelper.playOnPopupPlayer(context, queueFromHere(), true) + }, + Type.PlayFromHere.buildAction { context -> + NavigationHelper.playOnMainPlayer(context, queueFromHere(), false) + }, + ) + } + + private fun buildShareActionList(item: InfoItem): List { + return listOf( + Type.Share.buildAction { context -> + ShareUtils.shareText(context, item.name, item.url, item.thumbnails) + }, + Type.OpenInBrowser.buildAction { context -> + ShareUtils.openUrlInBrowser(context, item.url) + }, + ) + } + + private fun buildShareActionList(name: String, url: String, thumbnailUrl: String?): List { + return listOf( + Type.Share.buildAction { context -> + ShareUtils.shareText(context, name, url, thumbnailUrl) + }, + Type.OpenInBrowser.buildAction { context -> + ShareUtils.openUrlInBrowser(context, url) + }, + ) + } + + /** + * @param queueFromHere returns a play queue for the list that contains [item], with the + * queue index pointing to [item], used to build actions like "Play playlist from here". + */ + @JvmStatic + fun fromStreamInfoItem( + item: StreamInfoItem, + queueFromHere: (() -> PlayQueue)?, + /* TODO isKodiEnabled: Boolean, */ + ): List { + return buildPlayerActionList { SinglePlayQueue(item) } + + (queueFromHere?.let { buildPlayerFromHereActionList(queueFromHere) } ?: listOf()) + + buildShareActionList(item) + + listOf( + Type.Download.buildAction { context -> + SparseItemUtil.fetchStreamInfoAndSaveToDatabase( + context, item.serviceId, item.url + ) { info -> + val downloadDialog = DownloadDialog(context, info) + val fragmentManager = context.findFragmentActivity() + .supportFragmentManager + downloadDialog.show(fragmentManager, "downloadDialog") + } + }, + Type.AddToPlaylist.buildAction { context -> + PlaylistDialog.createCorrespondingDialog( + context, + listOf(StreamEntity(item)) + ) { dialog: PlaylistDialog -> + val tag = if (dialog is PlaylistAppendDialog) "append" else "create" + dialog.show( + context.findFragmentActivity().supportFragmentManager, + "StreamDialogEntry@${tag}_playlist" + ) + } + }, + Type.ShowChannelDetails.buildAction { context -> + SparseItemUtil.fetchUploaderUrlIfSparse( + context, item.serviceId, item.url, item.uploaderUrl + ) { url: String -> + NavigationHelper.openChannelFragment( + context.findFragmentActivity().supportFragmentManager, + item.serviceId, + url, + item.uploaderName, + ) + } + }, + Type.MarkAsWatched.buildAction { context -> + HistoryRecordManager(context) + .markAsWatched(item) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + }, + ) + /* TODO handle kodi + + if (isKodiEnabled) listOf( + Type.PlayWithKodi.buildAction { context -> + KoreUtils.playWithKore(context, Uri.parse(item.url)) + }, + ) else listOf()*/ + } + + @JvmStatic + fun fromStreamEntity( + item: StreamEntity, + queueFromHere: (() -> PlayQueue)?, + ): List { + // TODO decide if it's fine to just convert to StreamInfoItem here (it poses an + // unnecessary dependency on the extractor, when we want to just look at data; maybe + // using something like LongPressable would work) + return fromStreamInfoItem(item.toStreamInfoItem(), queueFromHere) + } + + @JvmStatic + fun fromStreamStatisticsEntry( + item: StreamStatisticsEntry, + queueFromHere: (() -> PlayQueue)?, + ): List { + return fromStreamEntity(item.streamEntity, queueFromHere) + + listOf( + Type.Delete.buildAction { context -> + HistoryRecordManager(context) + .deleteStreamHistoryAndState(item.streamId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + Toast.makeText( + context, + R.string.one_item_deleted, + Toast.LENGTH_SHORT + ).show() + } + } + ) + } + + @JvmStatic + fun fromPlaylistStreamEntry( + item: PlaylistStreamEntry, + queueFromHere: (() -> PlayQueue)?, + // TODO possibly embed these two actions here + onDelete: Runnable, + onSetAsPlaylistThumbnail: Runnable, + ): List { + return fromStreamEntity(item.streamEntity, queueFromHere) + + listOf( + Type.Delete.buildAction { onDelete.run() }, + Type.SetAsPlaylistThumbnail.buildAction { onSetAsPlaylistThumbnail.run() } + ) + } + + @JvmStatic + fun fromPlaylistMetadataEntry( + item: PlaylistMetadataEntry, + onRename: Runnable, + onDelete: Runnable, + unsetPlaylistThumbnail: Runnable?, + ): List { + return listOf( + Type.Rename.buildAction { onRename.run() }, + Type.Delete.buildAction { onDelete.run() }, + Type.UnsetPlaylistThumbnail.buildAction( + enabled = { unsetPlaylistThumbnail != null } + ) { unsetPlaylistThumbnail?.run() } + ) + } + + @JvmStatic + fun fromPlaylistRemoteEntity( + item: PlaylistRemoteEntity, + onDelete: Runnable, + ): List { + return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + + buildShareActionList(item.name, item.url, item.thumbnailUrl) + + listOf( + Type.Delete.buildAction { onDelete.run() }, + ) + } + + @JvmStatic + fun fromChannelInfoItem( + item: ChannelInfoItem, + onUnsubscribe: Runnable?, + ): List { + return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + + buildShareActionList(item) + + listOfNotNull( + Type.ShowChannelDetails.buildAction { context -> + NavigationHelper.openChannelFragment( + context.findFragmentActivity().supportFragmentManager, + item.serviceId, + item.url, + item.name, + ) + }, + onUnsubscribe?.let { r -> Type.Unsubscribe.buildAction { r.run() } } + ) + } + + @JvmStatic + fun fromPlaylistInfoItem(item: PlaylistInfoItem): List { + return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + + buildShareActionList(item) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt new file mode 100644 index 00000000000..1ac1e08eb39 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt @@ -0,0 +1,661 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.schabi.newpipe.ui.components.menu + +import android.app.Activity +import android.content.Context +import android.content.res.Configuration +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.ktx.popFirst +import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.ui.theme.customColors +import org.schabi.newpipe.util.Either +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.text.FixedHeightCenteredText +import org.schabi.newpipe.util.text.fadedMarquee +import java.time.OffsetDateTime + +fun openLongPressMenuInActivity( + activity: Activity, + longPressable: LongPressable, + longPressActions: List, +) { + activity.addContentView( + getLongPressMenuView(activity, longPressable, longPressActions), + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + ) +} + +fun getLongPressMenuView( + context: Context, + longPressable: LongPressable, + longPressActions: List, +): ComposeView { + return ComposeView(context).apply { + setContent { + AppTheme { + LongPressMenu( + longPressable = longPressable, + longPressActions = longPressActions, + onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, + ) + } + } + } +} + +internal val MinButtonWidth = 86.dp + +@Composable +fun LongPressMenu( + longPressable: LongPressable, + longPressActions: List, + onDismissRequest: () -> Unit, +) { + var showEditor by rememberSaveable(key = longPressable.url) { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (showEditor) { + // we can't put the editor in a bottom sheet, because it relies on dragging gestures + Dialog( + onDismissRequest = { showEditor = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + ScaffoldWithToolbar( + title = stringResource(R.string.long_press_menu_actions_editor), + onBackClick = { showEditor = false }, + ) { paddingValues -> + LongPressMenuEditor(modifier = Modifier.padding(paddingValues)) + } + } + } else { + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismissRequest, + dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }, + ) { + LongPressMenuContent( + longPressable = longPressable, + longPressActions = longPressActions, + onDismissRequest = onDismissRequest, + ) + } + } +} + +@Composable +private fun LongPressMenuContent( + longPressable: LongPressable, + longPressActions: List, + onDismissRequest: () -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp, end = 6.dp, bottom = 16.dp) + ) { + val buttonHeight = MinButtonWidth // landscape aspect ratio, square in the limit + val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons + val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() + + // the channel icon goes in the menu header, so do not show a button for it + val actions = longPressActions.toMutableList() + val ctx = LocalContext.current + val onUploaderClick = actions.popFirst { it.type == ShowChannelDetails } + ?.let { showChannelDetailsAction -> + { + showChannelDetailsAction.action(ctx) + onDismissRequest() + } + } + + Column { + var actionIndex = -1 // -1 indicates the header + while (actionIndex < actions.size) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + var rowIndex = 0 + while (rowIndex < buttonsPerRow) { + if (actionIndex >= actions.size) { + // no more buttons to show, fill the rest of the row with a + // spacer that has the same weight as the missing buttons, so that + // the other buttons don't grow too wide + Spacer( + modifier = Modifier + .height(buttonHeight) + .fillMaxWidth() + .weight((buttonsPerRow - rowIndex).toFloat()), + ) + break + } else if (actionIndex >= 0) { + val action = actions[actionIndex] + LongPressMenuButton( + icon = action.type.icon, + text = stringResource(action.type.label), + onClick = { + action.action(ctx) + onDismissRequest() + }, + enabled = action.enabled(false), + modifier = Modifier + .height(buttonHeight) + .fillMaxWidth() + .weight(1F), + ) + rowIndex += 1 + } else if (headerWidthInButtons >= buttonsPerRow) { + // this branch is taken if the header is going to fit on one line + // (i.e. on phones in portrait) + LongPressMenuHeader( + item = longPressable, + onUploaderClick = onUploaderClick, + modifier = Modifier + // leave the height as small as possible, since it's the + // only item on the row anyway + .padding(start = 6.dp, end = 6.dp, bottom = 6.dp) + .fillMaxWidth() + .weight(headerWidthInButtons.toFloat()), + ) + rowIndex += headerWidthInButtons + } else { + // this branch is taken if the header will have some buttons to its + // right (i.e. on tablets or on phones in landscape) + LongPressMenuHeader( + item = longPressable, + onUploaderClick = onUploaderClick, + modifier = Modifier + .padding(6.dp) + .heightIn(min = 70.dp) + .fillMaxWidth() + .weight(headerWidthInButtons.toFloat()), + ) + rowIndex += headerWidthInButtons + } + actionIndex += 1 + } + } + } + } + } +} + +@Composable +fun LongPressMenuDragHandle(onEditActions: () -> Unit) { + Box( + modifier = Modifier.fillMaxWidth() + ) { + BottomSheetDefaults.DragHandle( + modifier = Modifier.align(Alignment.Center) + ) + IconButton( + onClick = onEditActions, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + // show a small button here, it's not an important button and it shouldn't + // capture the user attention + Icon( + imageVector = Icons.Default.Tune, + contentDescription = stringResource(R.string.edit), + // same color and height as the DragHandle + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(2.dp) + .size(16.dp), + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun LongPressMenuDragHandlePreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + LongPressMenuDragHandle {} + } + } +} + +@Composable +fun LongPressMenuHeader( + item: LongPressable, + onUploaderClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val ctx = LocalContext.current + + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box { + if (item.thumbnailUrl != null) { + AsyncImage( + model = item.thumbnailUrl, + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_thumbnail_video), + error = painterResource(R.drawable.placeholder_thumbnail_video), + modifier = Modifier + .height(70.dp) + .widthIn(max = 125.dp) // 16:9 thumbnail at most + .clip(MaterialTheme.shapes.large) + ) + } + + when (val decoration = item.decoration) { + is LongPressable.Decoration.Duration -> { + // only show duration if there is a thumbnail + if (item.thumbnailUrl != null) { + Surface( + color = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(MaterialTheme.shapes.medium), + ) { + Text( + text = Localization.getDurationString(decoration.duration), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp), + ) + } + } + } + + is LongPressable.Decoration.Live -> { + // only show "Live" if there is a thumbnail + if (item.thumbnailUrl != null) { + Surface( + color = Color.Red.copy(alpha = 0.6f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(MaterialTheme.shapes.medium), + ) { + Text( + text = stringResource(R.string.duration_live).uppercase(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp) + ) + } + } + } + + is LongPressable.Decoration.Playlist -> { + Surface( + color = Color.Black.copy(alpha = 0.4f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.TopEnd) + .size(width = 40.dp, height = 70.dp) + .clip(MaterialTheme.shapes.large), + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Icons.AutoMirrored.Default.PlaylistPlay, + contentDescription = null, + ) + Text( + text = Localization.localizeStreamCountMini( + ctx, + decoration.itemCount + ), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } + } + } + + null -> {} + } + } + + Column( + modifier = Modifier.padding(vertical = 12.dp), + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .fadedMarquee(edgeWidth = 12.dp), + ) + + val subtitle = getSubtitleAnnotatedString( + item = item, + showLink = onUploaderClick != null, + linkColor = MaterialTheme.customColors.onSurfaceVariantLink, + ctx = ctx, + ) + if (subtitle.isNotBlank()) { + Spacer(Modifier.height(1.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + inlineContent = getSubtitleInlineContent(), + modifier = if (onUploaderClick == null) { + Modifier + } else { + Modifier.clickable(onClick = onUploaderClick) + } + .fillMaxWidth() + .fadedMarquee(edgeWidth = 12.dp), + ) + } + } + } + } +} + +fun getSubtitleAnnotatedString( + item: LongPressable, + showLink: Boolean, + linkColor: Color, + ctx: Context, +) = buildAnnotatedString { + var shouldAddSeparator = false + if (showLink) { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = linkColor, + textDecoration = TextDecoration.Underline + ) + ) { + if (item.uploader.isNullOrBlank()) { + append(ctx.getString(R.string.show_channel_details)) + } else { + append(item.uploader) + } + append(" ") + // see getSubtitleInlineContent() + appendInlineContent("open_in_new", "↗") + } + shouldAddSeparator = true + } else if (!item.uploader.isNullOrBlank()) { + append(item.uploader) + shouldAddSeparator = true + } + + val uploadDate = item.uploadDate?.match( + { it }, + { Localization.relativeTime(it) } + ) + if (!uploadDate.isNullOrBlank()) { + if (shouldAddSeparator) { + append(Localization.DOT_SEPARATOR) + } + shouldAddSeparator = true + append(uploadDate) + } + + val viewCount = item.viewCount?.let { + Localization.localizeViewCount(ctx, true, item.streamType, it) + } + if (!viewCount.isNullOrBlank()) { + if (shouldAddSeparator) { + append(Localization.DOT_SEPARATOR) + } + append(viewCount) + } +} + +/** + * [getSubtitleAnnotatedString] returns a string that might make use of the OpenInNew icon, and we + * provide it to [Text] through its `inlineContent` parameter. + */ +@Composable +fun getSubtitleInlineContent() = mapOf( + "open_in_new" to InlineTextContent( + placeholder = Placeholder( + width = MaterialTheme.typography.bodyMedium.fontSize, + height = MaterialTheme.typography.bodyMedium.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = MaterialTheme.customColors.onSurfaceVariantLink, + ) + } +) + +@Composable +fun LongPressMenuButton( + icon: ImageVector, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + // TODO possibly make it so that when you long-press on the button, the label appears on-screen + // as a small popup, so in case the label text is cut off the users can still read it in full + OutlinedButton( + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.large, + contentPadding = PaddingValues(start = 3.dp, top = 8.dp, end = 3.dp, bottom = 2.dp), + border = null, + modifier = modifier, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + FixedHeightCenteredText( + text = text, + lines = 2, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@ExperimentalLayoutApi +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun LongPressMenuButtonPreviews() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + FlowRow { + for (entry in LongPressAction.Type.entries) { + LongPressMenuButton( + icon = entry.icon, + text = stringResource(entry.label), + onClick = { }, + modifier = Modifier.size(86.dp) + ) + } + } + } + } +} + +private class LongPressablePreviews : CollectionPreviewParameterProvider( + listOf( + LongPressable( + title = "Big Buck Bunny", + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp", + uploader = "Blender", + uploaderUrl = "https://www.youtube.com/@BlenderOfficial", + viewCount = 8765432, + streamType = null, + uploadDate = Either.left("16 years ago"), + decoration = LongPressable.Decoration.Playlist(12), + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = null, + uploader = "Blender", + uploaderUrl = "https://www.youtube.com/@BlenderOfficial", + viewCount = 8765432, + streamType = StreamType.VIDEO_STREAM, + uploadDate = Either.left("16 years ago"), + decoration = LongPressable.Decoration.Duration(500), + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = null, + uploader = null, + uploaderUrl = "https://www.youtube.com/@BlenderOfficial", + viewCount = null, + streamType = null, + uploadDate = null, + decoration = null, + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp", + uploader = null, + uploaderUrl = null, + viewCount = null, + streamType = StreamType.AUDIO_STREAM, + uploadDate = null, + decoration = LongPressable.Decoration.Duration(500), + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp", + uploader = null, + uploaderUrl = null, + viewCount = null, + streamType = StreamType.LIVE_STREAM, + uploadDate = null, + decoration = LongPressable.Decoration.Live, + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = null, + uploader = null, + uploaderUrl = null, + viewCount = null, + streamType = StreamType.AUDIO_LIVE_STREAM, + uploadDate = Either.right(OffsetDateTime.now().minusSeconds(12)), + decoration = LongPressable.Decoration.Playlist(1500), + ), + ) +) + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(device = "spec:width=1280dp,height=800dp,dpi=240") +@Composable +private fun LongPressMenuPreview( + @PreviewParameter(LongPressablePreviews::class) longPressable: LongPressable +) { + DisposableEffect(null) { + Localization.initPrettyTime(Localization.resolvePrettyTime()) + onDispose {} + } + + // the incorrect theme is set when running the preview in an emulator for some reason... + val initialUseDarkTheme = isSystemInDarkTheme() + var useDarkTheme by remember { mutableStateOf(initialUseDarkTheme) } + + AppTheme(useDarkTheme = useDarkTheme) { + // longPressable is null when running the preview in an emulator for some reason... + @Suppress("USELESS_ELVIS") + LongPressMenuContent( + longPressable = longPressable ?: LongPressablePreviews().values.first(), + longPressActions = LongPressAction.Type.entries + // disable Enqueue actions just to show it off + .map { t -> t.buildAction({ t != EnqueueNext }) { } }, + onDismissRequest = {}, + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt new file mode 100644 index 00000000000..b2e8622502f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -0,0 +1,559 @@ +/* + * Copyright (C) 2022-2025 The FlorisBoard Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.schabi.newpipe.ui.components.menu + +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArtTrack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions +import org.schabi.newpipe.ui.detectDragGestures +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.text.FixedHeightCenteredText +import kotlin.math.floor +import kotlin.math.min + +@Composable +fun LongPressMenuEditor(modifier: Modifier = Modifier) { + // We get the current arrangement once and do not observe on purpose + // TODO load from settings + val headerEnabled = remember { true } + val actionArrangement = remember { DefaultEnabledActions } + val items = remember(headerEnabled, actionArrangement) { + sequence { + yield(ItemInList.EnabledCaption) + if (headerEnabled) { + yield(ItemInList.HeaderBox) + } + yieldAll( + actionArrangement + .map { ItemInList.Action(it) } + .ifEmpty { if (headerEnabled) listOf() else listOf(ItemInList.NoneMarker) } + ) + yield(ItemInList.HiddenCaption) + if (!headerEnabled) { + yield(ItemInList.HeaderBox) + } + yieldAll( + LongPressAction.Type.entries + .filter { !actionArrangement.contains(it) } + .map { ItemInList.Action(it) } + .ifEmpty { if (headerEnabled) listOf(ItemInList.NoneMarker) else listOf() } + ) + }.toList().toMutableStateList() + } + + val gridState = rememberLazyGridState() + var activeDragItem by remember { mutableStateOf(null) } + var activeDragPosition by remember { mutableStateOf(IntOffset.Zero) } + var activeDragSize by remember { mutableStateOf(IntSize.Zero) } + var currentlyFocusedItem by remember { mutableIntStateOf(-1) } + + fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { + var closestItemInRow: LazyGridItemInfo? = null + // Using manual for loop with indices instead of firstOrNull() because this method gets + // called a lot and firstOrNull allocates an iterator for each call + for (index in gridState.layoutInfo.visibleItemsInfo.indices) { + val item = gridState.layoutInfo.visibleItemsInfo[index] + if (offset.y in item.offset.y..(item.offset.y + item.size.height)) { + if (offset.x in item.offset.x..(item.offset.x + item.size.width)) { + return item + } + closestItemInRow = item + } + } + return closestItemInRow + } + + fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) { + if (activeDragItem != null) return + val item = items.getOrNull(rawItem.index) ?: return + if (item.isDraggable) { + items[rawItem.index] = ItemInList.DragMarker(item.columnSpan) + activeDragItem = item + activeDragPosition = pos + activeDragSize = rawItem.size + } + } + + fun beginDragGesture(pos: IntOffset) { + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + beginDragGesture(pos, rawItem) + } + + fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) { + val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + .takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list + + // compute where the DragMarker will go (we need to do special logic to make sure the + // HeaderBox always sticks right after EnabledCaption or HiddenCaption) + val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) { + val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) + if (rawItem.index < hiddenCaptionIndex) + 1 // i.e. right after the EnabledCaption + else if (prevDragMarkerIndex < hiddenCaptionIndex) + hiddenCaptionIndex // i.e. right after the HiddenCaption + else + hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption + } else { + var i = rawItem.index + // make sure it is not possible to move items in between a *Caption and a HeaderBox + if (!items[i].isDraggable) i += 1 + if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1 + if (i > rawItem.index && prevDragMarkerIndex < rawItem.index) i -= 1 + i + } + + // no need to do anything if the DragMarker is already at the right place + if (prevDragMarkerIndex == nextDragMarkerIndex) { + return + } + + // adjust the position of the DragMarker + items.removeAt(prevDragMarkerIndex) + items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan)) + + // add or remove NoneMarkers as needed + items.removeIf { it is ItemInList.NoneMarker } + val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) + if (hiddenCaptionIndex == items.size - 1) { + items.add(ItemInList.NoneMarker) + } else if (hiddenCaptionIndex == 1) { + items.add(1, ItemInList.NoneMarker) + } + } + + fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) { + val dragItem = activeDragItem + if (dragItem == null) { + // when the user clicks outside of any draggable item, let the list be scrolled + gridState.dispatchRawDelta(-posChangeForScrolling.y) + return + } + activeDragPosition = pos + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + handleDragGestureChange(dragItem, rawItem) + } + + fun completeDragGestureAndCleanUp() { + val dragItem = activeDragItem + if (dragItem != null) { + val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + if (dragMarkerIndex >= 0) { + items[dragMarkerIndex] = dragItem + } + } + activeDragItem = null + activeDragPosition = IntOffset.Zero + activeDragSize = IntSize.Zero + } + + DisposableEffect(Unit) { + onDispose { + completeDragGestureAndCleanUp() + // TODO save to settings + } + } + + BoxWithConstraints(modifier) { + // otherwise we wouldn't know the amount of columns to handle the Up/Down key events + val columns = maxOf(1, floor(this.maxWidth / MinButtonWidth).toInt()) + LazyVerticalGrid( + modifier = Modifier + .safeDrawingPadding() + .detectDragGestures( + beginDragGesture = ::beginDragGesture, + handleDragGestureChange = ::handleDragGestureChange, + endDragGesture = ::completeDragGestureAndCleanUp, + ) + .focusTarget() + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + if (event.type == KeyEventType.KeyUp && + event.key == Key.DirectionDown && + currentlyFocusedItem < 0 + ) { + // + currentlyFocusedItem = 0 + } + return@onKeyEvent false + } + var focusedItem = currentlyFocusedItem + when (event.key) { + Key.DirectionUp -> { + if (focusedItem < 0) { + return@onKeyEvent false + } else if (items[focusedItem].columnSpan == null) { + focusedItem -= 1 + } else { + var remaining = columns + while (true) { + focusedItem -= 1 + if (focusedItem < 0) { + break + } + remaining -= items[focusedItem].columnSpan ?: columns + if (remaining <= 0) { + break + } + } + } + } + + Key.DirectionDown -> { + if (focusedItem >= items.size - 1) { + return@onKeyEvent false + } else if (items[focusedItem].columnSpan == null) { + focusedItem += 1 + } else { + var remaining = columns + while (true) { + focusedItem += 1 + if (focusedItem >= items.size - 1) { + break + } + remaining -= items[focusedItem].columnSpan ?: columns + if (remaining <= 0) { + break + } + } + } + } + + Key.DirectionLeft -> { + if (focusedItem < 0) { + return@onKeyEvent false + } else { + focusedItem -= 1 + } + } + + Key.DirectionRight -> { + if (focusedItem >= items.size - 1) { + return@onKeyEvent false + } else { + focusedItem += 1 + } + } + + Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) { + val rawItem = gridState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == focusedItem } + ?: return@onKeyEvent false + beginDragGesture(rawItem.offset, rawItem) + return@onKeyEvent true + } else { + completeDragGestureAndCleanUp() + return@onKeyEvent true + } + + else -> return@onKeyEvent false + } + + currentlyFocusedItem = focusedItem + if (focusedItem < 0) { + // not checking for focusedItem>=items.size because it's impossible for it + // to reach that value, and that's because we assume that there is nothing + // else focusable *after* this view. This way we don't need to cleanup the + // drag gestures when the user reaches the end, which would be confusing as + // then there would be no indication of the current cursor position at all. + completeDragGestureAndCleanUp() + return@onKeyEvent false + } + + val dragItem = activeDragItem + if (dragItem != null) { + val rawItem = gridState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == focusedItem } + ?: return@onKeyEvent false + activeDragPosition = rawItem.offset + handleDragGestureChange(dragItem, rawItem) + } + return@onKeyEvent true + }, + // same width as the LongPressMenu + columns = GridCells.Adaptive(MinButtonWidth), + userScrollEnabled = false, + state = gridState, + ) { + itemsIndexed( + items, + key = { _, item -> item.stableUniqueKey() }, + span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, + ) { i, item -> + ItemInListUi( + item = item, + selected = currentlyFocusedItem == i, + modifier = Modifier.animateItem() + ) + } + } + if (activeDragItem != null) { + val size = with(LocalDensity.current) { + remember(activeDragSize) { activeDragSize.toSize().toDpSize() } + } + ItemInListUi( + item = activeDragItem!!, + selected = false, + modifier = Modifier + .size(size) + .offset { activeDragPosition } + .offset(-size.width / 2, -size.height / 2), + ) + } + } +} + +sealed class ItemInList(val isDraggable: Boolean, open val columnSpan: Int? = 1) { + // decoration items (i.e. text subheaders) + object EnabledCaption : ItemInList(isDraggable = false, columnSpan = null /* i.e. all line */) + object HiddenCaption : ItemInList(isDraggable = false, columnSpan = null /* i.e. all line */) + + // actual draggable actions (+ a header) + object HeaderBox : ItemInList(isDraggable = true, columnSpan = 2) + data class Action(val type: LongPressAction.Type) : ItemInList(isDraggable = true) + + // markers + object NoneMarker : ItemInList(isDraggable = true) + data class DragMarker(override val columnSpan: Int?) : ItemInList(isDraggable = true) + + fun stableUniqueKey(): Int { + return when (this) { + is Action -> this.type.ordinal + NoneMarker -> LongPressAction.Type.entries.size + 0 + HeaderBox -> LongPressAction.Type.entries.size + 1 + EnabledCaption -> LongPressAction.Type.entries.size + 2 + HiddenCaption -> LongPressAction.Type.entries.size + 3 + is DragMarker -> LongPressAction.Type.entries.size + 4 + (this.columnSpan ?: 0) + } + } +} + +inline fun T.letIf(condition: Boolean, block: T.() -> T): T = + if (condition) block(this) else this + +@Composable +private fun Subheader( + selected: Boolean, + @StringRes title: Int, + @StringRes description: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .letIf(selected) { border(2.dp, LocalContentColor.current) } + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(description), + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun ActionOrHeaderBox( + selected: Boolean, + icon: ImageVector, + @StringRes text: Int, + contentColor: Color, + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + horizontalPadding: Dp = 3.dp, +) { + Surface( + color = backgroundColor, + contentColor = contentColor, + shape = MaterialTheme.shapes.large, + border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { selected }, + modifier = modifier.padding( + horizontal = horizontalPadding, + vertical = 5.dp, + ), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + FixedHeightCenteredText( + text = stringResource(text), + lines = 2, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun ItemInListUi( + item: ItemInList, + selected: Boolean, + modifier: Modifier = Modifier, +) { + when (item) { + ItemInList.EnabledCaption -> { + Subheader( + modifier = modifier, + selected = selected, + title = R.string.long_press_menu_enabled_actions, + description = R.string.long_press_menu_enabled_actions_description, + ) + } + ItemInList.HiddenCaption -> { + Subheader( + modifier = modifier, + selected = selected, + title = R.string.long_press_menu_hidden_actions, + description = R.string.long_press_menu_hidden_actions_description, + ) + } + is ItemInList.Action -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = item.type.icon, + text = item.type.label, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + } + ItemInList.HeaderBox -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.ArtTrack, + text = R.string.header, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + horizontalPadding = 12.dp, + ) + } + ItemInList.NoneMarker -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.Close, + text = R.string.none, + // 0.38f is the same alpha that the Material3 library applies for disabled buttons + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + is ItemInList.DragMarker -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.DragHandle, + text = R.string.detail_drag_description, + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), + ) + } + } +} + +@Preview +@Composable +private fun LongPressMenuEditorPreview() { + AppTheme { + Surface { + LongPressMenuEditor() + } + } +} + +private class ItemInListPreviewProvider : CollectionPreviewParameterProvider( + listOf(ItemInList.HeaderBox, ItemInList.DragMarker(1), ItemInList.NoneMarker) + + LongPressAction.Type.entries.take(3).map { ItemInList.Action(it) } +) + +@Preview +@Composable +private fun QuickActionButtonPreview( + @PreviewParameter(ItemInListPreviewProvider::class) itemInList: ItemInList +) { + AppTheme { + Surface { + ItemInListUi( + item = itemInList, + selected = itemInList.stableUniqueKey() % 2 == 0, + modifier = Modifier.width(MinButtonWidth) + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt new file mode 100644 index 00000000000..23911ef86ec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt @@ -0,0 +1,126 @@ +package org.schabi.newpipe.ui.components.menu + +import androidx.compose.runtime.Stable +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.util.Either +import org.schabi.newpipe.util.image.ImageStrategy +import java.time.OffsetDateTime + +@Stable +data class LongPressable( + val title: String, + val url: String?, + val thumbnailUrl: String?, + val uploader: String?, + val uploaderUrl: String?, + val viewCount: Long?, + val streamType: StreamType?, // only used to format the view count properly + val uploadDate: Either?, + val decoration: Decoration?, +) { + sealed interface Decoration { + data class Duration(val duration: Long) : Decoration + data object Live : Decoration + data class Playlist(val itemCount: Long) : Decoration + + companion object { + internal fun from(streamType: StreamType, duration: Long) = + if (streamType == LIVE_STREAM || streamType == AUDIO_LIVE_STREAM) { + Live + } else { + duration.takeIf { it > 0 }?.let { Duration(it) } + } + } + } + + companion object { + @JvmStatic + fun fromStreamInfoItem(item: StreamInfoItem) = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploaderName?.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = item.viewCount.takeIf { it >= 0 }, + streamType = item.streamType, + uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } + ?: item.textualUploadDate?.let { Either.left(it) }, + decoration = Decoration.from(item.streamType, item.duration), + ) + + @JvmStatic + fun fromStreamEntity(item: StreamEntity) = LongPressable( + title = item.title, + url = item.url.takeIf { it.isNotBlank() }, + thumbnailUrl = item.thumbnailUrl, + uploader = item.uploader.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = item.viewCount?.takeIf { it >= 0 }, + streamType = item.streamType, + uploadDate = item.uploadDate?.let { Either.right(it) } + ?: item.textualUploadDate?.let { Either.left(it) }, + decoration = Decoration.from(item.streamType, item.duration), + ) + + @JvmStatic + fun fromPlaylistMetadataEntry(item: PlaylistMetadataEntry) = LongPressable( + // many fields are null because this is a local playlist + title = item.name, + url = null, + thumbnailUrl = item.thumbnailUrl, + uploader = null, + uploaderUrl = null, + viewCount = null, + streamType = null, + uploadDate = null, + decoration = Decoration.Playlist(item.streamCount), + ) + + @JvmStatic + fun fromPlaylistRemoteEntity(item: PlaylistRemoteEntity) = LongPressable( + title = item.name, + url = item.url, + thumbnailUrl = item.thumbnailUrl, + uploader = item.uploader, + uploaderUrl = null, + viewCount = null, + streamType = null, + uploadDate = null, + decoration = Decoration.Playlist(item.streamCount), + ) + + @JvmStatic + fun fromChannelInfoItem(item: ChannelInfoItem) = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = null, + uploaderUrl = item.url?.takeIf { it.isNotBlank() }, + viewCount = null, + streamType = null, + uploadDate = null, + decoration = null, + ) + + @JvmStatic + fun fromPlaylistInfoItem(item: PlaylistInfoItem) = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploaderName.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = null, + streamType = null, + uploadDate = null, + decoration = Decoration.Playlist(item.streamCount), + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt new file mode 100644 index 00000000000..573aa445c13 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt @@ -0,0 +1,151 @@ +@file:Suppress("UnusedReceiverParameter") + +package org.schabi.newpipe.ui.components.menu.icons + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/* + materialPath { + moveTo(12.0f, 4.0f) + lineToRelative(-1.41f, 1.41f) + lineToRelative(5.59f, 5.59f) + horizontalLineToRelative(-12.17f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(12.17f) + lineToRelative(-5.59f, 5.59f) + lineToRelative(1.41f, 1.41f) + lineToRelative(8.0f, -8.0f) + close() + } + */ + +/** + * Obtained by combining [androidx.compose.material.icons.filled.Headset] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + */ +val Icons.Filled.BackgroundFromHere: ImageVector by lazy { + materialIcon(name = "Filled.BackgroundFromHere") { + materialPath { + moveTo(12.0f, 1.0f) + curveToRelative(-4.97f, 0.0f, -9.0f, 4.03f, -9.0f, 9.0f) + verticalLineToRelative(7.0f) + curveToRelative(0.0f, 1.66f, 1.34f, 3.0f, 3.0f, 3.0f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(-8.0f) + horizontalLineTo(5.0f) + verticalLineToRelative(-2.0f) + curveToRelative(0.0f, -3.87f, 3.13f, -7.0f, 7.0f, -7.0f) + reflectiveCurveToRelative(7.0f, 3.13f, 7.0f, 7.0f) + horizontalLineToRelative(2.0f) + curveToRelative(0.0f, -4.97f, -4.03f, -9.0f, -9.0f, -9.0f) + close() + } + materialPath { + moveTo(19f, 11.5f) + lineToRelative(-1.42f, 1.41f) + lineToRelative(1.58f, 1.58f) + lineToRelative(-6.17f, 0.0f) + lineToRelative(0.0f, 2.0f) + lineToRelative(6.17f, 0.0f) + lineToRelative(-1.58f, 1.59f) + lineToRelative(1.42f, 1.41f) + lineToRelative(3.99f, -4.0f) + close() + } + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-5.622f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(-1.5f) + verticalLineToRelative(5.672f) + horizontalLineToRelative(1.5f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(5.622f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + } + materialPath { + moveTo(14f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-7.122f) + verticalLineToRelative(1.500f) + horizontalLineToRelative(7.122f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(17.200f, 11.200f) + lineToRelative(-0.775f, 0.775f) + lineToRelative(3.075f, 3.075f) + horizontalLineToRelative(-6.694f) + verticalLineToRelative(1.100f) + horizontalLineToRelative(6.694f) + lineToRelative(-3.075f, 3.075f) + lineToRelative(0.775f, 0.775f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(12.817f, 12.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + materialPath { + moveTo(17.100f, 12.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + }*/ + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun BackgroundFromHerePreview() { + Icon( + imageVector = Icons.Filled.BackgroundFromHere, + contentDescription = null, + modifier = Modifier.size(240.dp), + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt new file mode 100644 index 00000000000..0af14bbe333 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt @@ -0,0 +1,127 @@ +@file:Suppress("UnusedReceiverParameter") + +package org.schabi.newpipe.ui.components.menu.icons + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Obtained by combining [androidx.compose.material.icons.filled.PlayArrow] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + */ +val Icons.Filled.PlayFromHere: ImageVector by lazy { + materialIcon(name = "Filled.PlayFromHere") { + materialPath { + moveTo(2.5f, 2.5f) + verticalLineToRelative(14.0f) + lineToRelative(11.0f, -7.0f) + close() + } + materialPath { + moveTo(19f, 11.5f) + lineToRelative(-1.42f, 1.41f) + lineToRelative(1.58f, 1.58f) + lineToRelative(-6.17f, 0.0f) + lineToRelative(0.0f, 2.0f) + lineToRelative(6.17f, 0.0f) + lineToRelative(-1.58f, 1.59f) + lineToRelative(1.42f, 1.41f) + lineToRelative(3.99f, -4.0f) + close() + } + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-5.622f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(-1.5f) + verticalLineToRelative(5.672f) + horizontalLineToRelative(1.5f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(5.622f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + } + materialPath { + moveTo(14f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-7.122f) + verticalLineToRelative(1.500f) + horizontalLineToRelative(7.122f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(17.200f, 11.200f) + lineToRelative(-0.775f, 0.775f) + lineToRelative(3.075f, 3.075f) + horizontalLineToRelative(-6.694f) + verticalLineToRelative(1.100f) + horizontalLineToRelative(6.694f) + lineToRelative(-3.075f, 3.075f) + lineToRelative(0.775f, 0.775f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(15.817f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + materialPath { + moveTo(20.100f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + }*/ + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun PlayFromHerePreview() { + Icon( + imageVector = Icons.Filled.PlayFromHere, + contentDescription = null, + modifier = Modifier.size(240.dp), + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt new file mode 100644 index 00000000000..b33648a96ac --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt @@ -0,0 +1,163 @@ +@file:Suppress("UnusedReceiverParameter") + +package org.schabi.newpipe.ui.components.menu.icons + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Obtained by combining [androidx.compose.material.icons.filled.PictureInPicture] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + */ +val Icons.Filled.PopupFromHere: ImageVector by lazy { + materialIcon(name = "Filled.PopupFromHere") { + materialPath { + moveTo(19.0f, 5.0f) + horizontalLineToRelative(-8.0f) + verticalLineToRelative(5.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(-5.0f) + close() + moveTo(21.0f, 1.0f) + horizontalLineToRelative(-18.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(14.0f) + curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) + horizontalLineToRelative(8.5f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(-8.5f) + verticalLineToRelative(-14.0f) + horizontalLineToRelative(18.0f) + verticalLineToRelative(7.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-7.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + /*moveTo(21.0f, 17.01f) + horizontalLineToRelative(-18.0f) + verticalLineToRelative(-14.03f) + horizontalLineToRelative(18.0f) + verticalLineToRelative(14.03f) + close()*/ + } + materialPath { + moveTo(19f, 11.5f) + lineToRelative(-1.42f, 1.41f) + lineToRelative(1.58f, 1.58f) + lineToRelative(-6.17f, 0.0f) + lineToRelative(0.0f, 2.0f) + lineToRelative(6.17f, 0.0f) + lineToRelative(-1.58f, 1.59f) + lineToRelative(1.42f, 1.41f) + lineToRelative(3.99f, -4.0f) + close() + } + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-5.622f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(-1.5f) + verticalLineToRelative(5.672f) + horizontalLineToRelative(1.5f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(5.622f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + } + materialPath { + moveTo(14f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { QUESTO È PERFETTO + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-7.122f) + verticalLineToRelative(1.500f) + horizontalLineToRelative(7.122f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.600f, 11.200f) + lineToRelative(-0.775f, 0.775f) + lineToRelative(3.075f, 3.075f) + horizontalLineToRelative(-6.694f) + verticalLineToRelative(1.100f) + horizontalLineToRelative(6.694f) + lineToRelative(-3.075f, 3.075f) + lineToRelative(0.775f, 0.775f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.600f, 11.200f) + lineToRelative(-1.41f, 1.41f) + lineToRelative(1.99f, 1.99f) + horizontalLineToRelative(-6f) + verticalLineToRelative(2.00f) + horizontalLineToRelative(6f) + lineToRelative(-1.99f, 1.99f) + lineToRelative(1.41f, 1.41f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /* + materialPath { + moveTo(15.817f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + materialPath { + moveTo(20.100f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + }*/ + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun PopupFromHerePreview() { + Icon( + imageVector = Icons.Filled.PopupFromHere, + contentDescription = null, + modifier = Modifier.size(240.dp), + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt new file mode 100644 index 00000000000..aac91ee048a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt @@ -0,0 +1,32 @@ +package org.schabi.newpipe.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class CustomColors( + val onSurfaceVariantLink: Color = Color.Unspecified, +) + +val onSurfaceVariantLinkLight = Color(0xFF5060B0) + +val onSurfaceVariantLinkDark = Color(0xFFC0D0FF) + +val lightCustomColors = CustomColors( + onSurfaceVariantLink = onSurfaceVariantLinkLight +) + +val darkCustomColors = CustomColors( + onSurfaceVariantLink = onSurfaceVariantLinkDark +) + +val LocalCustomColors = staticCompositionLocalOf { CustomColors() } + +val MaterialTheme.customColors: CustomColors + @Composable + @ReadOnlyComposable + get() = LocalCustomColors.current diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt index d436b35a2e6..dbe4949841c 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.preference.PreferenceManager @@ -93,14 +94,18 @@ fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable val theme = sharedPreferences.getString("theme", "auto_device_theme") val nightTheme = sharedPreferences.getString("night_theme", "dark_theme") - MaterialTheme( - colorScheme = if (!useDarkTheme) { - lightScheme - } else if (theme == "black_theme" || nightTheme == "black_theme") { - blackScheme - } else { - darkScheme - }, - content = content - ) + CompositionLocalProvider( + LocalCustomColors provides if (!useDarkTheme) lightCustomColors else darkCustomColors + ) { + MaterialTheme( + colorScheme = if (!useDarkTheme) { + lightScheme + } else if (theme == "black_theme" || nightTheme == "black_theme") { + blackScheme + } else { + darkScheme + }, + content = content + ) + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/Either.kt b/app/src/main/java/org/schabi/newpipe/util/Either.kt new file mode 100644 index 00000000000..9d1f8f0f24c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/Either.kt @@ -0,0 +1,25 @@ +package org.schabi.newpipe.util + +import androidx.compose.runtime.Stable +import kotlin.reflect.KClass +import kotlin.reflect.cast +import kotlin.reflect.safeCast + +@Stable +data class Either( + val value: Any, + val classA: KClass, + val classB: KClass, +) { + inline fun match(ifLeft: (A) -> R, ifRight: (B) -> R): R { + return classA.safeCast(value)?.let { ifLeft(it) } + ?: ifRight(classB.cast(value)) + } + + companion object { + inline fun left(a: A): Either = + Either(a, A::class, B::class) + inline fun right(b: B): Either = + Either(b, A::class, B::class) + } +} 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 f5bcc40d3ea..f44157ad404 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -31,6 +31,7 @@ import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioTrackType; +import org.schabi.newpipe.extractor.stream.StreamType; import java.math.BigDecimal; import java.math.RoundingMode; @@ -183,9 +184,50 @@ public static String localizeUploadDate(@NonNull final Context context, return context.getString(R.string.upload_date_text, formatDate(offsetDateTime)); } - public static String localizeViewCount(@NonNull final Context context, final long viewCount) { + /** + * Localizes the number of views of a stream reported by the service, + * with different words based on the stream type. + * + * @param context the Android context + * @param shortForm whether the number of views should be formatted in a short approximated form + * @param streamType influences the accompanying text, i.e. views/watching/listening + * @param viewCount the number of views reported by the service to localize + * @return the formatted and localized view count + */ + public static String localizeViewCount(@NonNull final Context context, + final boolean shortForm, + @Nullable final StreamType streamType, + final long viewCount) { + final String localizedNumber; + if (shortForm) { + localizedNumber = shortCount(context, viewCount); + } else { + localizedNumber = localizeNumber(viewCount); + } + + if (streamType == StreamType.AUDIO_LIVE_STREAM) { + return getQuantity(context, R.plurals.listening, R.string.no_one_listening, viewCount, + localizedNumber); + } else if (streamType == StreamType.LIVE_STREAM) { + return getQuantity(context, R.plurals.watching, R.string.no_one_watching, viewCount, + localizedNumber); + } else { + return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, + localizedNumber); + } + } + + /** + * Localizes the number of times the user watched a video that they have in the history. + * + * @param context the Android context + * @param viewCount the number of times (stored in the database) the user watched a video + * @return the formatted and localized watch count + */ + public static String localizeWatchCount(@NonNull final Context context, + final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, - localizeNumber(viewCount)); + shortCount(context, viewCount)); } public static String localizeStreamCount(@NonNull final Context context, @@ -217,12 +259,6 @@ public static String localizeStreamCountMini(@NonNull final Context context, } } - public static String localizeWatchingCount(@NonNull final Context context, - final long watchingCount) { - return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, - localizeNumber(watchingCount)); - } - public static String shortCount(@NonNull final Context context, final long count) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return CompactDecimalFormat.getInstance(getAppLocale(), @@ -250,22 +286,6 @@ public static String shortCount(@NonNull final Context context, final long count } } - 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(@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(@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(@NonNull final Context context, final long subscriberCount) { return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, diff --git a/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt b/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt new file mode 100644 index 00000000000..e9d78c92ce9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt @@ -0,0 +1,54 @@ +package org.schabi.newpipe.util.text + +import androidx.compose.foundation.MarqueeSpacing +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp + +/** + * Note: the values in [basicMarquee] are hardcoded, but feel free to expose them as parameters + * in case that will be needed in the future. + * + * Taken from sample [androidx.compose.foundation.samples.BasicMarqueeWithFadedEdgesSample]. + */ +fun Modifier.fadedMarquee(edgeWidth: Dp): Modifier { + fun ContentDrawScope.drawFadedEdge(leftEdge: Boolean) { + val edgeWidthPx = edgeWidth.toPx() + drawRect( + topLeft = Offset(if (leftEdge) 0f else size.width - edgeWidthPx, 0f), + size = Size(edgeWidthPx, size.height), + brush = Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = if (leftEdge) 0f else size.width, + endX = if (leftEdge) edgeWidthPx else size.width - edgeWidthPx + ), + blendMode = BlendMode.DstIn + ) + } + + return this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + drawFadedEdge(leftEdge = true) + drawFadedEdge(leftEdge = false) + } + .basicMarquee( + repeatDelayMillis = 2000, + // wait some time before starting animations, to not distract the user + initialDelayMillis = 4000, + iterations = Int.MAX_VALUE, + spacing = MarqueeSpacing(edgeWidth) + ) + .padding(start = edgeWidth) +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt b/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt new file mode 100644 index 00000000000..57de2426968 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.util.text + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign + +/** + * Like [Text] but with a fixed bounding box of [lines] text lines, and with text always centered + * within it even when its actual length uses less than [lines] lines. + */ +@Composable +fun FixedHeightCenteredText( + text: String, + lines: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, +) { + Box(modifier = modifier) { + // this allows making the box always the same height (i.e. the height of [lines] text + // lines), while making the text appear centered if it is just a single line + Text( + text = "", + style = style, + minLines = lines, + ) + Text( + text = text, + style = style, + maxLines = lines, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.Center) + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c439f19e272..d0ddc049e2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -871,6 +871,7 @@ SoundCloud has discontinued the original Top 50 charts. The corresponding tab has been removed from your main page. Next NewPipeExtractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently. + Edit %d comment %d comments @@ -899,4 +900,14 @@ HTTP error 403 received from server while playing, likely caused by an IP ban or streaming URL deobfuscation issues %1$s refused to provide data, asking for a login to confirm the requester is not a bot.\n\nYour IP might have been temporarily banned by %1$s, you can wait some time or switch to a different IP (for example by turning on/off a VPN, or by switching from WiFi to mobile data). This content is not available for the currently selected content country.\n\nChange your selection from \"Settings > Content > Default content country\". + Background\nfrom here + Popup\nfrom here + Play\nfrom here + Enabled actions: + Reorder the actions by dragging them around + Hidden actions: + Drag the header or the actions to this section to hide them + Header + Back + Reorder and hide actions diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab8103909d8..d1095882229 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ teamnewpipe-nanojson = "e9d656ddb49a412a5a0a5d5ef20ca7ef09549996" # the corresponding commit hash, since JitPack sometimes deletes artifacts. # If there’s already a git hash, just add more of it to the end (or remove a letter) # to cause jitpack to regenerate the artifact. -teamnewpipe-newpipe-extractor = "0023b22095a2d62a60cdfc87f4b5cd85c8b266c3" +teamnewpipe-newpipe-extractor = "3af73262cc60cf555fd5f1d691f6c58e2db38ef5" webkit = "1.9.0" work = "2.10.0"