diff --git a/app/build.gradle b/app/build.gradle index ec7bc3776..c2a4f9265 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -230,7 +230,7 @@ dependencies { implementation libs.androidx.media implementation libs.androidx.preference implementation libs.androidx.recyclerview - implementation libs.androidx.room.runtime + implementation libs.androidx.room.ktx implementation libs.androidx.room.rxjava3 kapt libs.androidx.room.compiler implementation libs.androidx.swiperefreshlayout diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt index a34cfece6..5a098759c 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt @@ -7,6 +7,8 @@ import androidx.room.testing.MigrationTestHelper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull @@ -226,7 +228,7 @@ class DatabaseMigrationTest { } @Test - fun migrateDatabaseFrom8to9() { + fun migrateDatabaseFrom8to9() = runBlocking { val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8) val localUid1: Long @@ -283,8 +285,8 @@ class DatabaseMigrationTest { ) val migratedDatabaseV9 = getMigratedDatabase() - var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() - var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() + var localListFromDB = migratedDatabaseV9.playlistDAO().getAll().awaitFirst() + var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().awaitFirst() assertEquals(1, localListFromDB.size) assertEquals(localUid2, localListFromDB[0].uid) @@ -303,8 +305,8 @@ class DatabaseMigrationTest { ) ) - localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() - remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() + localListFromDB = migratedDatabaseV9.playlistDAO().getAll().awaitFirst() + remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().awaitFirst() assertEquals(2, localListFromDB.size) assertEquals(localUid3, localListFromDB[1].uid) assertEquals(-1, localListFromDB[1].displayIndex) diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java deleted file mode 100644 index ef77d5ade..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.schabi.newpipe.database.playlist.dao; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; - -@Dao -public interface PlaylistRemoteDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) - int deleteAll(); - - @Override - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - Flowable> listByService(int serviceId); - - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " - + REMOTE_PLAYLIST_ID + " = :playlistId") - Flowable getPlaylist(long playlistId); - - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " - + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - Flowable> getPlaylist(long serviceId, String url); - - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE - + " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX) - Flowable> getPlaylists(); - - @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_URL + " = :url " - + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - Long getPlaylistIdInternal(long serviceId, String url); - - @Transaction - default long upsert(final PlaylistRemoteEntity playlist) { - final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); - - if (playlistId == null) { - return insert(playlist); - } else { - playlist.setUid(playlistId); - update(playlist); - return playlistId; - } - } - - @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId") - int deletePlaylist(long playlistId); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt new file mode 100644 index 000000000..dffdddd97 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt @@ -0,0 +1,47 @@ +package org.schabi.newpipe.database.playlist.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.reactivex.rxjava3.core.Flowable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity + +@Dao +interface PlaylistRemoteDAO { + @Query("SELECT * FROM remote_playlists") + fun getAll(): Flowable> + + @Query("SELECT * FROM remote_playlists WHERE uid = :playlistId") + fun getPlaylist(playlistId: Long): Flowable + + @Query("SELECT * FROM remote_playlists WHERE url = :url AND service_id = :serviceId") + fun getPlaylist(serviceId: Int, url: String): Flow + + @Query("SELECT * FROM remote_playlists ORDER BY display_index") + fun getPlaylists(): Flowable> + + @Insert + suspend fun insert(playlist: PlaylistRemoteEntity): Long + + @Update + suspend fun update(playlist: PlaylistRemoteEntity) + + @Transaction + suspend fun upsert(playlist: PlaylistRemoteEntity) { + val dbPlaylist = getPlaylist(playlist.serviceId, playlist.url).firstOrNull() + + if (dbPlaylist == null) { + insert(playlist) + } else { + playlist.uid = dbPlaylist.uid + update(playlist) + } + } + + @Query("DELETE FROM remote_playlists WHERE uid = :playlistId") + suspend fun deletePlaylist(playlistId: Long): Int +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java index 0b0e3605e..13614348e 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java @@ -1,7 +1,5 @@ package org.schabi.newpipe.database.playlist.model; -import android.text.TextUtils; - import androidx.annotation.Nullable; import androidx.room.ColumnInfo; import androidx.room.Entity; @@ -10,12 +8,11 @@ import androidx.room.Index; import androidx.room.PrimaryKey; import org.schabi.newpipe.database.playlist.PlaylistLocalItem; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.ui.components.playlist.PlaylistScreenInfo; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.image.ImageStrategy; import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; @@ -85,7 +82,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { } @Ignore - public PlaylistRemoteEntity(final PlaylistInfo info) { + public PlaylistRemoteEntity(final PlaylistScreenInfo info) { this(info.getServiceId(), info.getName(), info.getUrl(), // use uploader avatar when no thumbnail is available ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty() @@ -93,23 +90,6 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { info.getUploaderName(), info.getStreamCount()); } - @Ignore - public boolean isIdenticalTo(final PlaylistInfo info) { - /* - * Returns boolean comparing the online playlist and the local copy. - * (False if info changed such as playlist name or track count) - */ - return getServiceId() == info.getServiceId() - && getStreamCount() == info.getStreamCount() - && TextUtils.equals(getName(), info.getName()) - && TextUtils.equals(getUrl(), info.getUrl()) - // we want to update the local playlist data even when either the remote thumbnail - // URL changes, or the preferred image quality setting is changed by the user - && TextUtils.equals(getThumbnailUrl(), - ImageStrategy.imageListToDbUrl(info.getThumbnails())) - && TextUtils.equals(getUploader(), info.getUploaderName()); - } - @Override public long getUid() { return uid; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 700d7d26e..464c89754 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -101,7 +101,6 @@ public class ChannelFragment extends BaseStateFragment private MenuItem menuRssButton; private MenuItem menuNotifyButton; private SubscriptionEntity channelSubscription; - private MenuProvider menuProvider; public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { @@ -135,71 +134,66 @@ public class ChannelFragment extends BaseStateFragment return binding.getRoot(); } - @Override - public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - menuProvider = new MenuProvider() { - @Override - public void onCreateMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - inflater.inflate(R.menu.menu_channel, menu); - - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - - } - - @Override - public void onPrepareMenu(@NonNull final Menu menu) { - menuRssButton = menu.findItem(R.id.menu_item_rss); - menuNotifyButton = menu.findItem(R.id.menu_item_notify); - updateRssButton(); - updateNotifyButton(channelSubscription); - } - - @Override - public boolean onMenuItemSelected(@NonNull final MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_item_notify: - final boolean value = !item.isChecked(); - item.setEnabled(false); - setNotify(value); - break; - case R.id.action_settings: - NavigationHelper.openSettings(requireContext()); - break; - case R.id.menu_item_rss: - if (currentInfo != null) { - ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); - } - break; - case R.id.menu_item_openInBrowser: - if (currentInfo != null) { - ShareUtils.openUrlInBrowser(requireContext(), - currentInfo.getOriginalUrl()); - } - break; - case R.id.menu_item_share: - if (currentInfo != null) { - ShareUtils.shareText(requireContext(), name, - currentInfo.getOriginalUrl(), currentInfo.getAvatars()); - } - break; - default: - return false; - } - return true; - } - }; - activity.addMenuProvider(menuProvider); - } - @Override // called from onViewCreated in BaseFragment.onViewCreated protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); + final var menuProvider = new MenuProvider() { + @Override + public void onCreateMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.menu_channel, menu); + + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + } + + @Override + public void onPrepareMenu(@NonNull final Menu menu) { + menuRssButton = menu.findItem(R.id.menu_item_rss); + menuNotifyButton = menu.findItem(R.id.menu_item_notify); + updateRssButton(); + updateNotifyButton(channelSubscription); + } + + @Override + public boolean onMenuItemSelected(@NonNull final MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_item_notify: + final boolean value = !item.isChecked(); + item.setEnabled(false); + setNotify(value); + break; + case R.id.action_settings: + NavigationHelper.openSettings(requireContext()); + break; + case R.id.menu_item_rss: + if (currentInfo != null) { + ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); + } + break; + case R.id.menu_item_openInBrowser: + if (currentInfo != null) { + ShareUtils.openUrlInBrowser(requireContext(), + currentInfo.getOriginalUrl()); + } + break; + case R.id.menu_item_share: + if (currentInfo != null) { + ShareUtils.shareText(requireContext(), name, + currentInfo.getOriginalUrl(), currentInfo.getAvatars()); + } + break; + default: + return false; + } + return true; + } + }; + activity.addMenuProvider(menuProvider, getViewLifecycleOwner()); + setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported); tabAdapter = new TabAdapter(getChildFragmentManager()); @@ -235,14 +229,6 @@ public class ChannelFragment extends BaseStateFragment binding.subChannelTitleView.setOnClickListener(openSubChannel); } - @Override - public void onDestroyView() { - super.onDestroyView(); - if (menuProvider != null) { - activity.removeMenuProvider(menuProvider); - } - } - @Override public void onDestroy() { super.onDestroy(); @@ -251,7 +237,6 @@ public class ChannelFragment extends BaseStateFragment } disposables.clear(); binding = null; - menuProvider = null; } /*////////////////////////////////////////////////////////////////////////// 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 deleted file mode 100644 index be4f076dd..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ /dev/null @@ -1,514 +0,0 @@ -package org.schabi.newpipe.fragments.list.playlist; - -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.util.ServiceHelper.getServiceById; - -import android.content.Context; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.content.res.AppCompatResources; - -import com.google.android.material.shape.CornerFamily; -import com.google.android.material.shape.ShapeAppearanceModel; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.PlaylistControlBinding; -import org.schabi.newpipe.databinding.PlaylistHeaderBinding; -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.ListExtractor; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; -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.util.ExtractorHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PlayButtonHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.CoilHelper; -import org.schabi.newpipe.util.text.TextEllipsizer; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import coil3.util.CoilUtils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; - -public class PlaylistFragment extends BaseListInfoFragment - implements PlaylistControlViewHolder { - - private CompositeDisposable disposables; - private Subscription bookmarkReactor; - private AtomicBoolean isBookmarkButtonReady; - - private RemotePlaylistManager remotePlaylistManager; - private PlaylistRemoteEntity playlistEntity; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private PlaylistHeaderBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; - - private MenuItem playlistBookmarkButton; - - private long streamCount; - private long playlistOverallDurationSeconds; - - public static PlaylistFragment getInstance(final int serviceId, final String url, - final String name) { - final PlaylistFragment instance = new PlaylistFragment(); - instance.setInitialData(serviceId, url, name); - return instance; - } - - public PlaylistFragment() { - super(UserAction.REQUESTED_PLAYLIST); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - disposables = new CompositeDisposable(); - isBookmarkButtonReady = new AtomicBoolean(false); - remotePlaylistManager = new RemotePlaylistManager(NewPipeDatabase - .getInstance(requireContext())); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_playlist, container, false); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Supplier getListHeaderSupplier() { - headerBinding = PlaylistHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding::getRoot; - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - // Is mini variant still relevant? - // Only the remote playlist screen uses it now - infoListAdapter.setUseMiniVariant(true); - } - - private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { - return getPlayQueue(Math.max(infoListAdapter.getItemsList().indexOf(infoItem), 0)); - } - - @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); - } - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_playlist, menu); - - playlistBookmarkButton = menu.findItem(R.id.menu_item_bookmark); - updateBookmarkButtons(); - } - - @Override - public void onDestroyView() { - headerBinding = null; - playlistControlBinding = null; - - super.onDestroyView(); - if (isBookmarkButtonReady != null) { - isBookmarkButtonReady.set(false); - } - - if (disposables != null) { - disposables.clear(); - } - if (bookmarkReactor != null) { - bookmarkReactor.cancel(); - } - - bookmarkReactor = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (disposables != null) { - disposables.dispose(); - } - - disposables = null; - remotePlaylistManager = null; - playlistEntity = null; - isBookmarkButtonReady = null; - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); - } - - @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getPlaylistInfo(serviceId, url, forceLoad); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - switch (item.getItemId()) { - case R.id.action_settings: - NavigationHelper.openSettings(requireContext()); - break; - case R.id.menu_item_openInBrowser: - ShareUtils.openUrlInBrowser(requireContext(), url); - break; - case R.id.menu_item_share: - ShareUtils.shareText(requireContext(), name, url, - currentInfo == null ? List.of() : currentInfo.getThumbnails()); - break; - case R.id.menu_item_bookmark: - onBookmarkClicked(); - break; - case R.id.menu_item_append_playlist: - if (currentInfo != null) { - disposables.add(PlaylistDialog.createCorrespondingDialog( - getContext(), - getPlayQueue() - .getStreams() - .stream() - .map(StreamEntity::new) - .collect(Collectors.toList()), - dialog -> dialog.show(getFM(), TAG) - )); - } - break; - default: - return super.onOptionsItemSelected(item); - } - return true; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animate(headerBinding.getRoot(), false, 200); - animateHideRecyclerViewAllowingScrolling(itemsList); - - CoilUtils.dispose(headerBinding.uploaderAvatarView); - animate(headerBinding.uploaderLayout, false, 200); - } - - @Override - public void handleNextItems(final ListExtractor.InfoItemsPage result) { - super.handleNextItems(result); - setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage()); - } - - @Override - public void handleResult(@NonNull final PlaylistInfo result) { - super.handleResult(result); - - animate(headerBinding.getRoot(), true, 100); - animate(headerBinding.uploaderLayout, true, 300); - headerBinding.uploaderLayout.setOnClickListener(null); - // If we have an uploader put them into the UI - if (!TextUtils.isEmpty(result.getUploaderName())) { - headerBinding.uploaderName.setText(result.getUploaderName()); - if (!TextUtils.isEmpty(result.getUploaderUrl())) { - headerBinding.uploaderLayout.setOnClickListener(v -> { - try { - NavigationHelper.openChannelFragment(getFM(), result.getServiceId(), - result.getUploaderUrl(), result.getUploaderName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - }); - } - } else { // Otherwise say we have no uploader - headerBinding.uploaderName.setText(R.string.playlist_no_uploader); - } - - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); - - if (result.getServiceId() == ServiceList.YouTube.getServiceId() - && (YoutubeParsingHelper.isYoutubeMixId(result.getId()) - || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { - // this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown - final ShapeAppearanceModel model = ShapeAppearanceModel.builder() - .setAllCorners(CornerFamily.ROUNDED, 0f) - .build(); // this turns the image back into a square - headerBinding.uploaderAvatarView.setShapeAppearanceModel(model); - headerBinding.uploaderAvatarView.setStrokeColor(AppCompatResources - .getColorStateList(requireContext(), R.color.transparent_background_color)); - headerBinding.uploaderAvatarView.setImageDrawable( - AppCompatResources.getDrawable(requireContext(), - R.drawable.ic_radio) - ); - } else { - CoilHelper.INSTANCE.loadAvatar(headerBinding.uploaderAvatarView, - result.getUploaderAvatars()); - } - - streamCount = result.getStreamCount(); - setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage()); - - final Description description = result.getDescription(); - if (description != null && description != Description.EMPTY_DESCRIPTION - && !isBlank(description.getContent())) { - final TextEllipsizer ellipsizer = new TextEllipsizer( - headerBinding.playlistDescription, 5, getServiceById(result.getServiceId())); - ellipsizer.setStateChangeListener(isEllipsized -> - headerBinding.playlistDescriptionReadMore.setText( - Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less - )); - ellipsizer.setOnContentChanged(canBeEllipsized -> { - headerBinding.playlistDescriptionReadMore.setVisibility( - Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE); - if (Boolean.TRUE.equals(canBeEllipsized)) { - ellipsizer.ellipsize(); - } - }); - ellipsizer.setContent(description); - headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle()); - headerBinding.playlistDescription.setOnClickListener(v -> ellipsizer.toggle()); - } else { - headerBinding.playlistDescription.setVisibility(View.GONE); - headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE); - } - - if (!result.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, - result.getUrl(), result)); - } - - remotePlaylistManager.getPlaylist(result) - .flatMap(lists -> getUpdateProcessor(lists, result), (lists, id) -> lists) - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getPlaylistBookmarkSubscriber()); - - PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); - } - - public PlayQueue getPlayQueue() { - return getPlayQueue(0); - } - - private PlayQueue getPlayQueue(final int index) { - final List infoItems = new ArrayList<>(); - for (final InfoItem i : infoListAdapter.getItemsList()) { - if (i instanceof StreamInfoItem) { - infoItems.add((StreamInfoItem) i); - } - } - return new PlaylistPlayQueue( - currentInfo.getServiceId(), - currentInfo.getUrl(), - currentInfo.getNextPage(), - infoItems, - index - ); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private Flowable getUpdateProcessor( - @NonNull final List playlists, - @NonNull final PlaylistInfo result) { - final Flowable noItemToUpdate = Flowable.just(/*noItemToUpdate=*/-1); - if (playlists.isEmpty()) { - return noItemToUpdate; - } - - final PlaylistRemoteEntity playlistRemoteEntity = playlists.get(0); - if (playlistRemoteEntity.isIdenticalTo(result)) { - return noItemToUpdate; - } - - return remotePlaylistManager.onUpdate(playlists.get(0).getUid(), result).toFlowable(); - } - - private Subscriber> getPlaylistBookmarkSubscriber() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - if (bookmarkReactor != null) { - bookmarkReactor.cancel(); - } - bookmarkReactor = s; - bookmarkReactor.request(1); - } - - @Override - public void onNext(final List playlist) { - playlistEntity = playlist.isEmpty() ? null : playlist.get(0); - - updateBookmarkButtons(); - isBookmarkButtonReady.set(true); - - if (bookmarkReactor != null) { - bookmarkReactor.request(1); - } - } - - @Override - public void onError(final Throwable throwable) { - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Get playlist bookmarks")); - } - - @Override - public void onComplete() { } - }; - } - - @Override - public void setTitle(final String title) { - super.setTitle(title); - if (headerBinding != null) { - headerBinding.playlistTitleView.setText(title); - } - } - - private void onBookmarkClicked() { - if (isBookmarkButtonReady == null || !isBookmarkButtonReady.get() - || remotePlaylistManager == null) { - return; - } - - final Disposable action; - - if (currentInfo != null && playlistEntity == null) { - action = remotePlaylistManager.onBookmark(currentInfo) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> { /* Do nothing */ }, throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Adding playlist bookmark"))); - } else if (playlistEntity != null) { - action = remotePlaylistManager.deletePlaylist(playlistEntity.getUid()) - .observeOn(AndroidSchedulers.mainThread()) - .doFinally(() -> playlistEntity = null) - .subscribe(ignored -> { /* Do nothing */ }, throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Deleting playlist bookmark"))); - } else { - action = Disposable.empty(); - } - - disposables.add(action); - } - - private void updateBookmarkButtons() { - if (playlistBookmarkButton == null || activity == null) { - return; - } - - final int drawable = playlistEntity == null - ? R.drawable.ic_playlist_add : R.drawable.ic_playlist_add_check; - - final int titleRes = playlistEntity == null - ? R.string.bookmark_playlist : R.string.unbookmark_playlist; - - playlistBookmarkButton.setIcon(drawable); - playlistBookmarkButton.setTitle(titleRes); - } - - private void setStreamCountAndOverallDuration(final List list, - final boolean isDurationComplete) { - if (activity != null && headerBinding != null) { - playlistOverallDurationSeconds += list.stream() - .mapToLong(x -> x.getDuration()) - .sum(); - headerBinding.playlistStreamCount.setText( - Localization.concatenateStrings( - Localization.localizeStreamCount(activity, streamCount), - Localization.getDurationString(playlistOverallDurationSeconds, - isDurationComplete, true)) - ); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt new file mode 100644 index 000000000..b63e00d91 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt @@ -0,0 +1,113 @@ +package org.schabi.newpipe.fragments.list.playlist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.Surface +import androidx.core.os.bundleOf +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.fragment.compose.content +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.screens.PlaylistScreen +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_TITLE +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.external_communication.ShareUtils +import org.schabi.newpipe.viewmodels.PlaylistViewModel + +class PlaylistFragment : Fragment() { + private val viewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = content { + AppTheme { + Surface { + PlaylistScreen(viewModel) + } + } + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + val activity = requireActivity() + + (activity as? AppCompatActivity)?.supportActionBar?.let { + it.setDisplayShowTitleEnabled(true) + it.title = viewModel.playlistTitle + } + + activity.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu( + menu: Menu, + menuInflater: MenuInflater, + ) { + menuInflater.inflate(R.menu.menu_playlist, menu) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.bookmarkFlow.collectLatest { + val bookmarkButton = menu.findItem(R.id.menu_item_bookmark) + bookmarkButton.setIcon(if (it == null) R.drawable.ic_playlist_add else R.drawable.ic_playlist_add_check) + bookmarkButton.setTitle(if (it == null) R.string.bookmark_playlist else R.string.unbookmark_playlist) + } + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + when (menuItem.itemId) { + R.id.action_settings -> { + NavigationHelper.openSettings(activity) + } + R.id.menu_item_openInBrowser -> { + ShareUtils.openUrlInBrowser(activity, viewModel.url) + } + R.id.menu_item_bookmark -> { + viewModel.toggleBookmark() + } + R.id.menu_item_share -> { + ShareUtils.shareText(activity, viewModel.playlistTitle, viewModel.url) + } + } + return true + } + }, + viewLifecycleOwner, + ) + } + + companion object { + @JvmStatic + fun getInstance( + serviceId: Int, + url: String, + playlistName: String, + ) = PlaylistFragment().apply { + arguments = bundleOf( + KEY_SERVICE_ID to serviceId, + KEY_URL to url, + KEY_TITLE to playlistName, + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java index 447c540a0..c203ef781 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java @@ -5,7 +5,7 @@ package org.schabi.newpipe.info_list; */ public enum ItemViewMode { /** - * Default mode. + * View mode is automatically determined based on the device configuration. */ AUTO, /** 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 499625332..e8b260bb9 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 @@ -16,7 +16,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.compose.ui.platform.ComposeView; -import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; @@ -140,20 +139,14 @@ public final class BookmarkFragment extends BaseLocalListFragment() { @Override public void selected(final LocalItem selectedItem) { - final FragmentManager fragmentManager = getFM(); + final var fragmentManager = getFM(); - if (selectedItem instanceof PlaylistMetadataEntry) { - final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); + if (selectedItem instanceof PlaylistMetadataEntry entry) { NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), entry.name); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - NavigationHelper.openPlaylistFragment( - fragmentManager, - entry.getServiceId(), - entry.getUrl(), - entry.getName()); + } else if (selectedItem instanceof PlaylistRemoteEntity entry) { + NavigationHelper.openPlaylistFragment(fragmentManager, entry.getServiceId(), + entry.getUrl(), entry.getName()); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java deleted file mode 100644 index 08b203a7e..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.schabi.newpipe.local.playlist; - -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; - -import java.util.List; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class RemotePlaylistManager { - - private final AppDatabase database; - private final PlaylistRemoteDAO playlistRemoteTable; - - public RemotePlaylistManager(final AppDatabase db) { - database = db; - playlistRemoteTable = db.playlistRemoteDAO(); - } - - public Flowable> getPlaylists() { - return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io()); - } - - public Flowable getPlaylist(final long playlistId) { - return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()); - } - - public Flowable> getPlaylist(final PlaylistInfo info) { - return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl()) - .subscribeOn(Schedulers.io()); - } - - public Single deletePlaylist(final long playlistId) { - return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId)) - .subscribeOn(Schedulers.io()); - } - - public Completable updatePlaylists(final List updateItems, - final List deletedItems) { - return Completable.fromRunnable(() -> database.runInTransaction(() -> { - for (final Long uid: deletedItems) { - playlistRemoteTable.deletePlaylist(uid); - } - for (final PlaylistRemoteEntity item: updateItems) { - playlistRemoteTable.upsert(item); - } - })).subscribeOn(Schedulers.io()); - } - - public Single onBookmark(final PlaylistInfo playlistInfo) { - return Single.fromCallable(() -> { - final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); - return playlistRemoteTable.upsert(playlist); - }).subscribeOn(Schedulers.io()); - } - - public Single onUpdate(final long playlistId, final PlaylistInfo playlistInfo) { - return Single.fromCallable(() -> { - final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); - playlist.setUid(playlistId); - return playlistRemoteTable.update(playlist); - }).subscribeOn(Schedulers.io()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt new file mode 100644 index 000000000..e04443d50 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt @@ -0,0 +1,36 @@ +package org.schabi.newpipe.local.playlist + +import androidx.room.withTransaction +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.rx3.rxCompletable +import org.schabi.newpipe.database.AppDatabase +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity + +class RemotePlaylistManager(db: AppDatabase) { + private val database = db + private val playlistRemoteTable = db.playlistRemoteDAO() + + fun getPlaylist(playlistId: Long): Flowable { + return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()) + } + + fun getPlaylists(): Flowable> { + return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io()) + } + + fun updatePlaylists( + updateItems: List, + deletedItems: List, + ) = rxCompletable(Dispatchers.IO) { + database.withTransaction { + for (uid in deletedItems) { + playlistRemoteTable.deletePlaylist(uid) + } + for (item in updateItems) { + playlistRemoteTable.upsert(item) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/paging/PlaylistItemsSource.kt b/app/src/main/java/org/schabi/newpipe/paging/PlaylistItemsSource.kt new file mode 100644 index 000000000..c1ce00de9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/paging/PlaylistItemsSource.kt @@ -0,0 +1,28 @@ +package org.schabi.newpipe.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.components.playlist.PlaylistScreenInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfo as ExtractorPlaylistInfo + +class PlaylistItemsSource( + private val playlist: PlaylistScreenInfo, +) : PagingSource() { + private val service = NewPipe.getService(playlist.serviceId) + + override suspend fun load(params: LoadParams): LoadResult { + return params.key?.let { + withContext(Dispatchers.IO) { + val response = ExtractorPlaylistInfo.getMoreItems(service, playlist.url, it) + LoadResult.Page(response.items, null, response.nextPage) + } + } ?: LoadResult.Page(playlist.relatedItems, null, playlist.nextPage) + } + + override fun getRefreshKey(state: PagingState) = null +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java index 4c1f65df2..3e642ab0f 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java @@ -580,7 +580,6 @@ public abstract class Tab { public Fragment getFragment(final Context context) { if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) { return LocalPlaylistFragment.getInstance(playlistId, playlistName); - } else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM return PlaylistFragment.getInstance(playlistServiceId, playlistUrl, playlistName); } @@ -609,12 +608,10 @@ public abstract class Tab { @Override public boolean equals(final Object obj) { - if (!(obj instanceof PlaylistTab)) { + if (!(obj instanceof PlaylistTab other)) { return false; } - final PlaylistTab other = (PlaylistTab) obj; - return super.equals(obj) && playlistServiceId == other.playlistServiceId // Remote && playlistId == other.playlistId // Local diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt index 40c5903c5..1b7347e93 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.fromHtml @@ -20,12 +21,14 @@ fun DescriptionText( modifier: Modifier = Modifier, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, - style: TextStyle = LocalTextStyle.current + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, ) { Text( modifier = modifier, text = rememberParsedDescription(description), maxLines = maxLines, + onTextLayout = onTextLayout, style = style, overflow = overflow ) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/IconButtonWithLabel.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/IconButtonWithLabel.kt new file mode 100644 index 000000000..d767542bb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/IconButtonWithLabel.kt @@ -0,0 +1,53 @@ +package org.schabi.newpipe.ui.components.common + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.theme.AppTheme + +@Composable +fun IconButtonWithLabel( + icon: ImageVector, + @StringRes label: Int, + onClick: () -> Unit, +) { + FilledTonalButton( + contentPadding = PaddingValues(vertical = 8.dp, horizontal = 12.dp), + onClick = onClick + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = icon, contentDescription = null) + Text(text = stringResource(label)) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun IconButtonWithLabelPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + IconButtonWithLabel(Icons.Default.Info, R.string.name) {} + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/PlaybackControlButtons.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/PlaybackControlButtons.kt new file mode 100644 index 000000000..d8aa2fae3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/PlaybackControlButtons.kt @@ -0,0 +1,50 @@ +package org.schabi.newpipe.ui.components.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.PictureInPicture +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.R +import org.schabi.newpipe.ktx.findFragmentActivity +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.util.NavigationHelper + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PlaybackControlButtons( + queue: PlayQueue, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally) + ) { + IconButtonWithLabel( + icon = Icons.Default.Headphones, + label = R.string.controls_background_title, + onClick = { NavigationHelper.playOnBackgroundPlayer(context, queue, false) }, + ) + + IconButtonWithLabel( + icon = Icons.AutoMirrored.Filled.PlaylistPlay, + label = R.string.play_all, + onClick = { NavigationHelper.playOnMainPlayer(context.findFragmentActivity(), queue) }, + ) + + IconButtonWithLabel( + icon = Icons.Default.PictureInPicture, + label = R.string.controls_popup_title, + onClick = { NavigationHelper.playOnPopupPlayer(context, queue, false) }, + ) + } +} 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 4562e17af..4c238e422 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 @@ -1,7 +1,12 @@ package org.schabi.newpipe.ui.components.items +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable @@ -14,8 +19,12 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems import androidx.preference.PreferenceManager import androidx.window.core.layout.WindowWidthSizeClass +import my.nanihadesuka.compose.LazyVerticalGridScrollbar import org.schabi.newpipe.R import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem @@ -23,16 +32,21 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar +import org.schabi.newpipe.ui.components.common.defaultThemedScrollbarSettings import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem +import org.schabi.newpipe.ui.components.items.stream.StreamCardItem +import org.schabi.newpipe.ui.components.items.stream.StreamGridItem import org.schabi.newpipe.ui.components.items.stream.StreamListItem +import org.schabi.newpipe.ui.emptystate.EmptyStateComposable +import org.schabi.newpipe.ui.emptystate.EmptyStateSpec import org.schabi.newpipe.util.DependentPreferenceHelper import org.schabi.newpipe.util.NavigationHelper @Composable fun ItemList( - items: List, + items: LazyPagingItems, mode: ItemViewMode = determineItemViewMode(), - listHeader: LazyListScope.() -> Unit = {} + header: @Composable () -> Unit = {} ) { val context = LocalContext.current val onClick = remember { @@ -67,23 +81,57 @@ fun ItemList( val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context) val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()) - if (mode == ItemViewMode.GRID) { - // TODO: Implement grid layout using LazyVerticalGrid and LazyVerticalGridScrollbar. + if (items.loadState.refresh is LoadState.NotLoading && items.itemCount == 0) { + EmptyStateComposable( + spec = EmptyStateSpec.NoVideos, + modifier = Modifier.fillMaxWidth().heightIn(min = 128.dp), + ) + } else if (mode == ItemViewMode.GRID) { + val gridState = rememberLazyGridState() + + LazyVerticalGridScrollbar(state = gridState, settings = defaultThemedScrollbarSettings()) { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isCompact = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT + val minSize = if (isCompact) 150.dp else 250.dp + + LazyVerticalGrid(state = gridState, columns = GridCells.Adaptive(minSize)) { + item(span = { GridItemSpan(maxLineSpan) }) { + header() + } + + items(items.itemCount) { + val item = items[it]!! + + // TODO: Handle channel and playlist items. + if (item is StreamInfoItem) { + val isSelected = selectedStream == item + + StreamGridItem(item, showProgress, isSelected, isCompact, onClick, onLongClick, onDismissPopup) + } + } + } + } } else { val state = rememberLazyListState() LazyColumnThemedScrollbar(state = state) { LazyColumn(modifier = nestedScrollModifier, state = state) { - listHeader() + item { + header() + } - items(items.size) { - val item = items[it] + items(items.itemCount) { + val item = items[it]!! + // TODO: Handle channel items. if (item is StreamInfoItem) { val isSelected = selectedStream == item - StreamListItem( - item, showProgress, isSelected, onClick, onLongClick, onDismissPopup - ) + + if (mode == ItemViewMode.CARD) { + StreamCardItem(item, showProgress, isSelected, onClick, onLongClick, onDismissPopup) + } else { + StreamListItem(item, showProgress, isSelected, onClick, onLongClick, onDismissPopup) + } } else if (item is PlaylistInfoItem) { PlaylistListItem(item, onClick) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamCardItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamCardItem.kt new file mode 100644 index 000000000..12d6bfbe7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamCardItem.kt @@ -0,0 +1,88 @@ +package org.schabi.newpipe.ui.components.items.stream + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +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.theme.AppTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StreamCardItem( + stream: StreamInfoItem, + showProgress: Boolean, + isSelected: Boolean, + onClick: (StreamInfoItem) -> Unit = {}, + onLongClick: (StreamInfoItem) -> Unit = {}, + onDismissPopup: () -> Unit = {} +) { + Box { + Column( + modifier = Modifier + .combinedClickable( + onLongClick = { onLongClick(stream) }, + onClick = { onClick(stream) } + ) + .padding(top = 12.dp, start = 2.dp, end = 2.dp) + ) { + StreamThumbnail( + stream = stream, + showProgress = showProgress, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + + Column(modifier = Modifier.padding(10.dp)) { + Text( + text = stream.name, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + maxLines = 2 + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall) + + Text( + text = getStreamInfoDetail(stream), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + StreamMenu(stream, isSelected, onDismissPopup) + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamCardItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamCardItem(stream, showProgress = false, isSelected = false) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamGridItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamGridItem.kt new file mode 100644 index 000000000..44df1eb6b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamGridItem.kt @@ -0,0 +1,94 @@ +package org.schabi.newpipe.ui.components.items.stream + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.theme.AppTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StreamGridItem( + stream: StreamInfoItem, + showProgress: Boolean, + isSelected: Boolean = false, + isMini: Boolean = false, + onClick: (StreamInfoItem) -> Unit = {}, + onLongClick: (StreamInfoItem) -> Unit = {}, + onDismissPopup: () -> Unit = {} +) { + Box { + Column( + modifier = Modifier + .combinedClickable( + onLongClick = { onLongClick(stream) }, + onClick = { onClick(stream) } + ) + .padding(12.dp) + ) { + val size = if (isMini) DpSize(150.dp, 85.dp) else DpSize(246.dp, 138.dp) + + StreamThumbnail( + stream = stream, + showProgress = showProgress, + modifier = Modifier.size(size) + ) + + Text( + text = stream.name, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + maxLines = 2 + ) + + Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall) + + Text( + text = getStreamInfoDetail(stream), + style = MaterialTheme.typography.bodySmall + ) + } + + StreamMenu(stream, isSelected, onDismissPopup) + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamGridItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamGridItem(stream, showProgress = false) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamMiniGridItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamGridItem(stream, showProgress = false, isMini = true) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt new file mode 100644 index 000000000..37bfadcba --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt @@ -0,0 +1,191 @@ +package org.schabi.newpipe.ui.components.playlist + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorUtil +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.ktx.findFragmentActivity +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.ui.components.common.DescriptionText +import org.schabi.newpipe.ui.components.common.PlaybackControlButtons +import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.image.ImageStrategy + +@Composable +fun PlaylistHeader( + playlistScreenInfo: PlaylistScreenInfo, + streams: List, +) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(text = playlistScreenInfo.name, style = MaterialTheme.typography.titleMedium) + + // Paging's load states only indicate when loading is currently happening, not if it can/will + // happen. As such, the duration initially displayed will be the incomplete duration if more + // items can be loaded. + PlaylistStats(playlistScreenInfo, streams.sumOf { it?.duration ?: 0 }) + + if (playlistScreenInfo.description != Description.EMPTY_DESCRIPTION) { + var isExpanded by rememberSaveable { mutableStateOf(false) } + var isExpandable by rememberSaveable { mutableStateOf(false) } + + DescriptionText( + modifier = Modifier.animateContentSize(), + description = playlistScreenInfo.description, + maxLines = if (isExpanded) Int.MAX_VALUE else 5, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + onTextLayout = { + if (it.hasVisualOverflow) { + isExpandable = true + } + } + ) + + if (isExpandable) { + TextButton( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier.align(Alignment.End) + ) { + Text( + text = stringResource(if (isExpanded) R.string.show_less else R.string.show_more) + ) + } + } + } + + PlaybackControlButtons( + modifier = Modifier.align(Alignment.CenterHorizontally), + queue = SinglePlayQueue(streams.filterNotNull(), 0), + ) + } +} + +@Composable +private fun PlaylistStats( + playlistScreenInfo: PlaylistScreenInfo, + totalDuration: Long +) { + val context = LocalContext.current + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.clickable( + playlistScreenInfo.uploaderName != null && playlistScreenInfo.uploaderUrl != null, + ) { + try { + NavigationHelper.openChannelFragment( + context.findFragmentActivity().supportFragmentManager, + playlistScreenInfo.serviceId, + playlistScreenInfo.uploaderUrl, + playlistScreenInfo.uploaderName!!, + ) + } catch (e: Exception) { + ErrorUtil.showUiErrorSnackbar(context, "Opening channel fragment", e) + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + val isMix = YoutubeParsingHelper.isYoutubeMixId(playlistScreenInfo.id) || + YoutubeParsingHelper.isYoutubeMusicMixId(playlistScreenInfo.id) + val isYoutubeMix = playlistScreenInfo.serviceId == ServiceList.YouTube.serviceId && isMix + val url = ImageStrategy.choosePreferredImage(playlistScreenInfo.uploaderAvatars) + + AsyncImage( + model = url?.takeUnless { isYoutubeMix }, + contentDescription = stringResource(R.string.playlist_uploader_icon_description), + placeholder = painterResource(R.drawable.placeholder_person), + error = painterResource(R.drawable.placeholder_person), + fallback = painterResource(if (isYoutubeMix) R.drawable.ic_radio else R.drawable.placeholder_person), + modifier = Modifier + .size(24.dp) + .border(BorderStroke(1.dp, Color.White), CircleShape) + .padding(1.dp) + .clip(CircleShape), + ) + + val uploader = playlistScreenInfo.uploaderName.orEmpty() + .ifEmpty { stringResource(R.string.playlist_no_uploader) } + Text(text = uploader, style = MaterialTheme.typography.bodySmall) + } + + val count = Localization.localizeStreamCount(context, playlistScreenInfo.streamCount) + val formattedDuration = Localization.getDurationString(totalDuration, true, true) + Text(text = "$count • $formattedDuration", style = MaterialTheme.typography.bodySmall) + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaylistHeaderPreview() { + val description = Description("Example description", Description.PLAIN_TEXT) + val playlistScreenInfo = PlaylistScreenInfo( + id = "", + serviceId = 1, + url = "", + name = "Example playlist", + description = description, + relatedItems = listOf(), + streamCount = 1L, + uploaderUrl = null, + uploaderName = "Uploader", + uploaderAvatars = listOf(), + thumbnails = listOf(), + nextPage = null + ) + + AppTheme { + Surface { + PlaylistHeader( + playlistScreenInfo = playlistScreenInfo, + streams = listOf(StreamInfoItem(streamType = StreamType.NONE)), + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistScreenInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistScreenInfo.kt new file mode 100644 index 000000000..0f71db075 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistScreenInfo.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.ui.components.playlist + +import androidx.compose.runtime.Immutable +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +@Immutable +class PlaylistScreenInfo( + val id: String, + val serviceId: Int, + val url: String, + val name: String, + val description: Description, + val relatedItems: List, + val streamCount: Long, + val uploaderUrl: String?, + val uploaderName: String?, + val uploaderAvatars: List, + val thumbnails: List, + val nextPage: Page? +) { + constructor(playlistInfo: PlaylistInfo) : this( + playlistInfo.id, + playlistInfo.serviceId, + playlistInfo.url, + playlistInfo.name, + playlistInfo.description ?: Description.EMPTY_DESCRIPTION, + playlistInfo.relatedItems, + playlistInfo.streamCount, + playlistInfo.uploaderUrl, + playlistInfo.uploaderName, + playlistInfo.uploaderAvatars, + playlistInfo.thumbnails, + playlistInfo.nextPage, + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt index 5d77488c5..e98e2fec1 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt @@ -4,7 +4,6 @@ import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -21,15 +20,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.edit +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems import androidx.preference.PreferenceManager +import kotlinx.coroutines.flow.flowOf import org.schabi.newpipe.R import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.ui.components.items.ItemList import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem -import org.schabi.newpipe.ui.emptystate.EmptyStateComposable -import org.schabi.newpipe.ui.emptystate.EmptyStateSpec import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.NO_SERVICE_ID @@ -43,43 +43,29 @@ fun RelatedItems(info: StreamInfo) { } ItemList( - items = info.relatedItems, + items = flowOf(PagingData.from(info.relatedItems)).collectAsLazyPagingItems(), mode = ItemViewMode.LIST, - listHeader = { - item { + header = { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(R.string.auto_queue_description)) + Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(text = stringResource(R.string.auto_queue_description)) - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = stringResource(R.string.auto_queue_toggle)) - Switch( - checked = isAutoQueueEnabled, - onCheckedChange = { - isAutoQueueEnabled = it - sharedPreferences.edit { - putBoolean(key, it) - } + Text(text = stringResource(R.string.auto_queue_toggle)) + Switch( + checked = isAutoQueueEnabled, + onCheckedChange = { + isAutoQueueEnabled = it + sharedPreferences.edit { + putBoolean(key, it) } - ) - } - } - } - if (info.relatedItems.isEmpty()) { - item { - EmptyStateComposable( - spec = EmptyStateSpec.NoVideos, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 128.dp) + } ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt index 77fa02082..349f96721 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt @@ -96,6 +96,10 @@ enum class EmptyStateSpec( emojiText = "¯\\_(╹x╹)_/¯", descriptionText = R.string.error_unable_to_load_comments, ), + ErrorLoadingItems( + emojiText = "¯\\_(╹x╹)_/¯", + descriptionText = R.string.error_unable_to_load_items, + ), NoSearchResult( emojiText = "╰(°●°╰)", descriptionText = R.string.search_no_results, diff --git a/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt b/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt new file mode 100644 index 000000000..ba0ccb461 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt @@ -0,0 +1,84 @@ +package org.schabi.newpipe.ui.screens + +import android.content.res.Configuration +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.ui.components.items.ItemList +import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem +import org.schabi.newpipe.ui.components.playlist.PlaylistHeader +import org.schabi.newpipe.ui.components.playlist.PlaylistScreenInfo +import org.schabi.newpipe.ui.emptystate.EmptyStateComposable +import org.schabi.newpipe.ui.emptystate.EmptyStateSpec +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.viewmodels.PlaylistViewModel +import org.schabi.newpipe.viewmodels.util.Resource + +@Composable +fun PlaylistScreen(playlistViewModel: PlaylistViewModel = viewModel()) { + val uiState by playlistViewModel.uiState.collectAsStateWithLifecycle() + PlaylistScreen(uiState, playlistViewModel.streamItems) +} + +@Composable +private fun PlaylistScreen( + uiState: Resource, + streamFlow: Flow> +) { + when (uiState) { + is Resource.Success -> { + val info = uiState.data + val streams = streamFlow.collectAsLazyPagingItems() + + ItemList( + items = streams, + header = { PlaylistHeader(info, streams.itemSnapshotList) }, + ) + } + + is Resource.Loading -> LoadingIndicator() + + // TODO use error panel instead + is Resource.Error -> EmptyStateComposable(EmptyStateSpec.ErrorLoadingItems) + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaylistPreview() { + val description = Description("Example description", Description.PLAIN_TEXT) + val playlistScreenInfo = PlaylistScreenInfo( + id = "", + serviceId = 1, + url = "", + name = "Example playlist", + description = description, + relatedItems = listOf(), + streamCount = 1L, + uploaderUrl = null, + uploaderName = "Uploader", + uploaderAvatars = listOf(), + thumbnails = listOf(), + nextPage = null, + ) + val stream = StreamInfoItem(streamType = StreamType.VIDEO_STREAM) + val streamFlow = flowOf(PagingData.from(listOf(stream))) + + AppTheme { + Surface { + PlaylistScreen(Resource.Success(playlistScreenInfo), streamFlow) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index cc260d254..9833cd032 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -9,13 +9,13 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; +import android.os.Bundle; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -119,7 +119,7 @@ public final class NavigationHelper { } /* PLAY */ - public static void playOnMainPlayer(final AppCompatActivity activity, + public static void playOnMainPlayer(final FragmentActivity activity, @NonNull final PlayQueue playQueue) { final PlayQueueItem item = playQueue.getItem(); if (item != null) { @@ -504,8 +504,13 @@ public final class NavigationHelper { public static void openPlaylistFragment(final FragmentManager fragmentManager, final int serviceId, final String url, @NonNull final String name) { + final var args = new Bundle(); + args.putInt(Constants.KEY_SERVICE_ID, serviceId); + args.putString(Constants.KEY_URL, url); + args.putString(Constants.KEY_TITLE, name); + defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name)) + .replace(R.id.fragment_holder, PlaylistFragment.class, args) .addToBackStack(null) .commit(); } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java deleted file mode 100644 index 184b73304..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.graphics.Paint; -import android.text.Layout; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; - -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; - -import java.util.function.Consumer; - - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -/** - *

Class to ellipsize text inside a {@link TextView}.

- * This class provides all utils to automatically ellipsize and expand a text - */ -public final class TextEllipsizer { - private static final int EXPANDED_LINES = Integer.MAX_VALUE; - private static final String ELLIPSIS = "…"; - - @NonNull private final CompositeDisposable disposable = new CompositeDisposable(); - - @NonNull private final TextView view; - private final int maxLines; - @NonNull private Description content; - @Nullable private StreamingService streamingService; - @Nullable private String streamUrl; - private boolean isEllipsized = false; - @Nullable private Boolean canBeEllipsized = null; - - @NonNull private final Paint paintAtContentSize = new Paint(); - private final float ellipsisWidthPx; - @Nullable private Consumer stateChangeListener = null; - @Nullable private Consumer onContentChanged; - - public TextEllipsizer(@NonNull final TextView view, - final int maxLines, - @Nullable final StreamingService streamingService) { - this.view = view; - this.maxLines = maxLines; - this.content = Description.EMPTY_DESCRIPTION; - this.streamingService = streamingService; - - paintAtContentSize.setTextSize(view.getTextSize()); - ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); - } - - public void setOnContentChanged(@Nullable final Consumer onContentChanged) { - this.onContentChanged = onContentChanged; - } - - public void setContent(@NonNull final Description content) { - this.content = content; - canBeEllipsized = null; - linkifyContentView(v -> { - final int currentMaxLines = view.getMaxLines(); - view.setMaxLines(EXPANDED_LINES); - canBeEllipsized = view.getLineCount() > maxLines; - view.setMaxLines(currentMaxLines); - if (onContentChanged != null) { - onContentChanged.accept(canBeEllipsized); - } - }); - } - - public void setStreamUrl(@Nullable final String streamUrl) { - this.streamUrl = streamUrl; - } - - public void setStreamingService(@NonNull final StreamingService streamingService) { - this.streamingService = streamingService; - } - - /** - * Expand the {@link TextEllipsizer#content} to its full length. - */ - public void expand() { - view.setMaxLines(EXPANDED_LINES); - linkifyContentView(v -> isEllipsized = false); - } - - /** - * Shorten the {@link TextEllipsizer#content} to the given number of - * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}' - * if the text was shorted. - */ - public void ellipsize() { - // expand text to see whether it is necessary to ellipsize the text - view.setMaxLines(EXPANDED_LINES); - linkifyContentView(v -> { - final CharSequence charSeqText = view.getText(); - if (charSeqText != null && view.getLineCount() > maxLines) { - // Note that converting to String removes spans (i.e. links), but that's something - // we actually want since when the text is ellipsized we want all clicks on the - // comment to expand the comment, not to open links. - final String text = charSeqText.toString(); - - final Layout layout = view.getLayout(); - final float lineWidth = layout.getLineWidth(maxLines - 1); - final float layoutWidth = layout.getWidth(); - final int lineStart = layout.getLineStart(maxLines - 1); - final int lineEnd = layout.getLineEnd(maxLines - 1); - - // remove characters up until there is enough space for the ellipsis - // (also summing 2 more pixels, just to be sure to avoid float rounding errors) - int end = lineEnd; - float removedCharactersWidth = 0.0f; - while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth - && end >= lineStart) { - end -= 1; - // recalculate each time to account for ligatures or other similar things - removedCharactersWidth = paintAtContentSize.measureText( - text.substring(end, lineEnd)); - } - - // remove trailing spaces and newlines - while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { - end -= 1; - } - - final String newVal = text.substring(0, end) + ELLIPSIS; - view.setText(newVal); - isEllipsized = true; - } else { - isEllipsized = false; - } - view.setMaxLines(maxLines); - }); - } - - /** - * Toggle the view between the ellipsized and expanded state. - */ - public void toggle() { - if (isEllipsized) { - expand(); - } else { - ellipsize(); - } - } - - /** - * Whether the {@link #view} can be ellipsized. - * This is only the case when the {@link #content} has more lines - * than allowed via {@link #maxLines}. - * @return {@code true} if the {@link #content} has more lines than allowed via - * {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into - * the {@link #view} without being shortened and {@code null} if the initialization is not - * completed yet. - */ - @Nullable - public Boolean canBeEllipsized() { - return canBeEllipsized; - } - - private void linkifyContentView(final Consumer consumer) { - final boolean oldState = isEllipsized; - disposable.clear(); - TextLinkifier.fromDescription(view, content, - HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable, - v -> { - consumer.accept(v); - notifyStateChangeListener(oldState); - }); - - } - - /** - * Add a listener which is called when the given content is changed, - * either from ellipsized to full or vice versa. - * @param listener The listener to be called, or {@code null} to remove it. - * The Boolean parameter is the new state. - * Ellipsized content is represented as {@code true}, - * normal or full content by {@code false}. - */ - public void setStateChangeListener(@Nullable final Consumer listener) { - this.stateChangeListener = listener; - } - - private void notifyStateChangeListener(final boolean oldState) { - if (oldState != isEllipsized && stateChangeListener != null) { - stateChangeListener.accept(isEllipsized); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/PlaylistViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/PlaylistViewModel.kt new file mode 100644 index 000000000..4f7a931f2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/PlaylistViewModel.kt @@ -0,0 +1,92 @@ +package org.schabi.newpipe.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.withContext +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.paging.PlaylistItemsSource +import org.schabi.newpipe.ui.components.playlist.PlaylistScreenInfo +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_TITLE +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.viewmodels.util.Resource + +class PlaylistViewModel( + application: Application, + savedStateHandle: SavedStateHandle, +) : AndroidViewModel(application) { + val serviceId = savedStateHandle.get(KEY_SERVICE_ID)!! + val url = savedStateHandle.get(KEY_URL)!! + val playlistTitle = savedStateHandle.get(KEY_TITLE)!! + + private val _uiState = MutableStateFlow>(Resource.Loading) + val uiState = _uiState.asStateFlow() + + private val remotePlaylistDao = NewPipeDatabase.getInstance(application).playlistRemoteDAO() + val bookmarkFlow = remotePlaylistDao.getPlaylist(serviceId, url) + + @OptIn(ExperimentalCoroutinesApi::class) + val streamItems = _uiState + .filterIsInstance>() + .flatMapLatest { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + PlaylistItemsSource(it.data) + }.flow + } + .flowOn(Dispatchers.IO) + .cachedIn(viewModelScope) + + init { + loadPlaylist() + } + + fun loadPlaylist() { + _uiState.value = Resource.Loading + viewModelScope.launch { + _uiState.value = + try { + val extractorInfo = withContext(Dispatchers.IO) { + ExtractorHelper.getPlaylistInfo(serviceId, url, true).await() + } + Resource.Success(PlaylistScreenInfo(extractorInfo)) + } catch (e: Exception) { + Resource.Error(e) + } + } + } + + fun toggleBookmark() { + viewModelScope.launch { + val bookmark = bookmarkFlow.firstOrNull() + if (bookmark != null) { + remotePlaylistDao.deletePlaylist(bookmark.uid) + } else { + val info = _uiState + .filterIsInstance>() + .firstOrNull() + ?.data + if (info != null) { + val entity = PlaylistRemoteEntity(info) + remotePlaylistDao.upsert(entity) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c439f19e2..75dbf299b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -871,6 +871,8 @@ 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. + Could not load items + Playlist uploader icon %d comment %d comments diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bea2550ad..0fd16bbc0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,7 +106,7 @@ androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } -androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-rxjava3 = { group = "androidx.room", name = "room-rxjava3", version.ref = "room" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }