1
0
Fork 0
mirror of https://github.com/TeamNewPipe/NewPipe.git synced 2025-10-03 09:49:21 +02:00
This commit is contained in:
Isira Seneviratne 2025-09-11 00:11:58 +02:00 committed by GitHub
commit d3c25ff439
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1087 additions and 1011 deletions

View file

@ -230,7 +230,7 @@ dependencies {
implementation libs.androidx.media implementation libs.androidx.media
implementation libs.androidx.preference implementation libs.androidx.preference
implementation libs.androidx.recyclerview implementation libs.androidx.recyclerview
implementation libs.androidx.room.runtime implementation libs.androidx.room.ktx
implementation libs.androidx.room.rxjava3 implementation libs.androidx.room.rxjava3
kapt libs.androidx.room.compiler kapt libs.androidx.room.compiler
implementation libs.androidx.swiperefreshlayout implementation libs.androidx.swiperefreshlayout

View file

@ -7,6 +7,8 @@ import androidx.room.testing.MigrationTestHelper
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.reactive.awaitFirst
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@ -226,7 +228,7 @@ class DatabaseMigrationTest {
} }
@Test @Test
fun migrateDatabaseFrom8to9() { fun migrateDatabaseFrom8to9() = runBlocking {
val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8) val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8)
val localUid1: Long val localUid1: Long
@ -283,8 +285,8 @@ class DatabaseMigrationTest {
) )
val migratedDatabaseV9 = getMigratedDatabase() val migratedDatabaseV9 = getMigratedDatabase()
var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() var localListFromDB = migratedDatabaseV9.playlistDAO().getAll().awaitFirst()
var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().awaitFirst()
assertEquals(1, localListFromDB.size) assertEquals(1, localListFromDB.size)
assertEquals(localUid2, localListFromDB[0].uid) assertEquals(localUid2, localListFromDB[0].uid)
@ -303,8 +305,8 @@ class DatabaseMigrationTest {
) )
) )
localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() localListFromDB = migratedDatabaseV9.playlistDAO().getAll().awaitFirst()
remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().awaitFirst()
assertEquals(2, localListFromDB.size) assertEquals(2, localListFromDB.size)
assertEquals(localUid3, localListFromDB[1].uid) assertEquals(localUid3, localListFromDB[1].uid)
assertEquals(-1, localListFromDB[1].displayIndex) assertEquals(-1, localListFromDB[1].displayIndex)

View file

@ -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<PlaylistRemoteEntity> {
@Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE)
Flowable<List<PlaylistRemoteEntity>> getAll();
@Override
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE)
int deleteAll();
@Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistRemoteEntity>> 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);
}

View file

@ -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<List<PlaylistRemoteEntity>>
@Query("SELECT * FROM remote_playlists WHERE uid = :playlistId")
fun getPlaylist(playlistId: Long): Flowable<PlaylistRemoteEntity>
@Query("SELECT * FROM remote_playlists WHERE url = :url AND service_id = :serviceId")
fun getPlaylist(serviceId: Int, url: String): Flow<PlaylistRemoteEntity?>
@Query("SELECT * FROM remote_playlists ORDER BY display_index")
fun getPlaylists(): Flowable<List<PlaylistRemoteEntity>>
@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
}

View file

@ -1,7 +1,5 @@
package org.schabi.newpipe.database.playlist.model; package org.schabi.newpipe.database.playlist.model;
import android.text.TextUtils;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.room.ColumnInfo; import androidx.room.ColumnInfo;
import androidx.room.Entity; import androidx.room.Entity;
@ -10,12 +8,11 @@ import androidx.room.Index;
import androidx.room.PrimaryKey; import androidx.room.PrimaryKey;
import org.schabi.newpipe.database.playlist.PlaylistLocalItem; 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.Constants;
import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.image.ImageStrategy;
import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; 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_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_TABLE;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
@ -85,7 +82,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
} }
@Ignore @Ignore
public PlaylistRemoteEntity(final PlaylistInfo info) { public PlaylistRemoteEntity(final PlaylistScreenInfo info) {
this(info.getServiceId(), info.getName(), info.getUrl(), this(info.getServiceId(), info.getName(), info.getUrl(),
// use uploader avatar when no thumbnail is available // use uploader avatar when no thumbnail is available
ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty() ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty()
@ -93,23 +90,6 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
info.getUploaderName(), info.getStreamCount()); 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 @Override
public long getUid() { public long getUid() {
return uid; return uid;

View file

@ -101,7 +101,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
private MenuItem menuRssButton; private MenuItem menuRssButton;
private MenuItem menuNotifyButton; private MenuItem menuNotifyButton;
private SubscriptionEntity channelSubscription; private SubscriptionEntity channelSubscription;
private MenuProvider menuProvider;
public static ChannelFragment getInstance(final int serviceId, final String url, public static ChannelFragment getInstance(final int serviceId, final String url,
final String name) { final String name) {
@ -135,71 +134,66 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
return binding.getRoot(); 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 @Override // called from onViewCreated in BaseFragment.onViewCreated
protected void initViews(final View rootView, final Bundle savedInstanceState) { protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, 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); setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported);
tabAdapter = new TabAdapter(getChildFragmentManager()); tabAdapter = new TabAdapter(getChildFragmentManager());
@ -235,14 +229,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
binding.subChannelTitleView.setOnClickListener(openSubChannel); binding.subChannelTitleView.setOnClickListener(openSubChannel);
} }
@Override
public void onDestroyView() {
super.onDestroyView();
if (menuProvider != null) {
activity.removeMenuProvider(menuProvider);
}
}
@Override @Override
public void onDestroy() { public void onDestroy() {
super.onDestroy(); super.onDestroy();
@ -251,7 +237,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
} }
disposables.clear(); disposables.clear();
binding = null; binding = null;
menuProvider = null;
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////

View file

@ -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<StreamInfoItem, PlaylistInfo>
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<View> 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<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
}
@Override
protected Single<PlaylistInfo> 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<StreamInfoItem> 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<Integer> getUpdateProcessor(
@NonNull final List<PlaylistRemoteEntity> playlists,
@NonNull final PlaylistInfo result) {
final Flowable<Integer> 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<List<PlaylistRemoteEntity>> 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<PlaylistRemoteEntity> 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<StreamInfoItem> 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))
);
}
}
}

View file

@ -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<PlaylistViewModel>()
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,
)
}
}
}

View file

@ -5,7 +5,7 @@ package org.schabi.newpipe.info_list;
*/ */
public enum ItemViewMode { public enum ItemViewMode {
/** /**
* Default mode. * View mode is automatically determined based on the device configuration.
*/ */
AUTO, AUTO,
/** /**

View file

@ -16,7 +16,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.compose.ui.platform.ComposeView; import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -140,20 +139,14 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
itemListAdapter.setSelectedListener(new OnClickGesture<>() { itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override @Override
public void selected(final LocalItem selectedItem) { public void selected(final LocalItem selectedItem) {
final FragmentManager fragmentManager = getFM(); final var fragmentManager = getFM();
if (selectedItem instanceof PlaylistMetadataEntry) { if (selectedItem instanceof PlaylistMetadataEntry entry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(),
entry.name); entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity entry) {
} else if (selectedItem instanceof PlaylistRemoteEntity) { NavigationHelper.openPlaylistFragment(fragmentManager, entry.getServiceId(),
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); entry.getUrl(), entry.getName());
NavigationHelper.openPlaylistFragment(
fragmentManager,
entry.getServiceId(),
entry.getUrl(),
entry.getName());
} }
} }

View file

@ -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<List<PlaylistRemoteEntity>> getPlaylists() {
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
}
public Flowable<PlaylistRemoteEntity> getPlaylist(final long playlistId) {
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
.subscribeOn(Schedulers.io());
}
public Single<Integer> deletePlaylist(final long playlistId) {
return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId))
.subscribeOn(Schedulers.io());
}
public Completable updatePlaylists(final List<PlaylistRemoteEntity> updateItems,
final List<Long> 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<Long> onBookmark(final PlaylistInfo playlistInfo) {
return Single.fromCallable(() -> {
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);
return playlistRemoteTable.upsert(playlist);
}).subscribeOn(Schedulers.io());
}
public Single<Integer> 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());
}
}

View file

@ -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<PlaylistRemoteEntity> {
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io())
}
fun getPlaylists(): Flowable<List<PlaylistRemoteEntity>> {
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io())
}
fun updatePlaylists(
updateItems: List<PlaylistRemoteEntity>,
deletedItems: List<Long>,
) = rxCompletable(Dispatchers.IO) {
database.withTransaction {
for (uid in deletedItems) {
playlistRemoteTable.deletePlaylist(uid)
}
for (item in updateItems) {
playlistRemoteTable.upsert(item)
}
}
}
}

View file

@ -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<Page, StreamInfoItem>() {
private val service = NewPipe.getService(playlist.serviceId)
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, StreamInfoItem> {
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<Page, StreamInfoItem>) = null
}

View file

@ -580,7 +580,6 @@ public abstract class Tab {
public Fragment getFragment(final Context context) { public Fragment getFragment(final Context context) {
if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) { if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) {
return LocalPlaylistFragment.getInstance(playlistId, playlistName); return LocalPlaylistFragment.getInstance(playlistId, playlistName);
} else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM } else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM
return PlaylistFragment.getInstance(playlistServiceId, playlistUrl, playlistName); return PlaylistFragment.getInstance(playlistServiceId, playlistUrl, playlistName);
} }
@ -609,12 +608,10 @@ public abstract class Tab {
@Override @Override
public boolean equals(final Object obj) { public boolean equals(final Object obj) {
if (!(obj instanceof PlaylistTab)) { if (!(obj instanceof PlaylistTab other)) {
return false; return false;
} }
final PlaylistTab other = (PlaylistTab) obj;
return super.equals(obj) return super.equals(obj)
&& playlistServiceId == other.playlistServiceId // Remote && playlistServiceId == other.playlistServiceId // Remote
&& playlistId == other.playlistId // Local && playlistId == other.playlistId // Local

View file

@ -7,6 +7,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.fromHtml
@ -20,12 +21,14 @@ fun DescriptionText(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
overflow: TextOverflow = TextOverflow.Clip, overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) { ) {
Text( Text(
modifier = modifier, modifier = modifier,
text = rememberParsedDescription(description), text = rememberParsedDescription(description),
maxLines = maxLines, maxLines = maxLines,
onTextLayout = onTextLayout,
style = style, style = style,
overflow = overflow overflow = overflow
) )

View file

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

View file

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

View file

@ -1,7 +1,12 @@
package org.schabi.newpipe.ui.components.items 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.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.foundation.lazy.rememberLazyListState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable 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.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource 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.preference.PreferenceManager
import androidx.window.core.layout.WindowWidthSizeClass import androidx.window.core.layout.WindowWidthSizeClass
import my.nanihadesuka.compose.LazyVerticalGridScrollbar
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem 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.info_list.ItemViewMode
import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.ktx.findFragmentActivity
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar 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.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.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.DependentPreferenceHelper
import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.NavigationHelper
@Composable @Composable
fun ItemList( fun ItemList(
items: List<InfoItem>, items: LazyPagingItems<out InfoItem>,
mode: ItemViewMode = determineItemViewMode(), mode: ItemViewMode = determineItemViewMode(),
listHeader: LazyListScope.() -> Unit = {} header: @Composable () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val onClick = remember { val onClick = remember {
@ -67,23 +81,57 @@ fun ItemList(
val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context) val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context)
val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()) val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
if (mode == ItemViewMode.GRID) { if (items.loadState.refresh is LoadState.NotLoading && items.itemCount == 0) {
// TODO: Implement grid layout using LazyVerticalGrid and LazyVerticalGridScrollbar. 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 { } else {
val state = rememberLazyListState() val state = rememberLazyListState()
LazyColumnThemedScrollbar(state = state) { LazyColumnThemedScrollbar(state = state) {
LazyColumn(modifier = nestedScrollModifier, state = state) { LazyColumn(modifier = nestedScrollModifier, state = state) {
listHeader() item {
header()
}
items(items.size) { items(items.itemCount) {
val item = items[it] val item = items[it]!!
// TODO: Handle channel items.
if (item is StreamInfoItem) { if (item is StreamInfoItem) {
val isSelected = selectedStream == item 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) { } else if (item is PlaylistInfoItem) {
PlaylistListItem(item, onClick) PlaylistListItem(item, onClick)
} }

View file

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

View file

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

View file

@ -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<StreamInfoItem?>,
) {
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)),
)
}
}
}

View file

@ -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<StreamInfoItem>,
val streamCount: Long,
val uploaderUrl: String?,
val uploaderName: String?,
val uploaderAvatars: List<Image>,
val thumbnails: List<Image>,
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,
)
}

View file

@ -4,7 +4,6 @@ import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.flowOf
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.ui.components.items.ItemList import org.schabi.newpipe.ui.components.items.ItemList
import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem 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.ui.theme.AppTheme
import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.NO_SERVICE_ID
@ -43,43 +43,29 @@ fun RelatedItems(info: StreamInfo) {
} }
ItemList( ItemList(
items = info.relatedItems, items = flowOf(PagingData.from(info.relatedItems)).collectAsLazyPagingItems(),
mode = ItemViewMode.LIST, mode = ItemViewMode.LIST,
listHeader = { header = {
item { Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = stringResource(R.string.auto_queue_description))
Row( Row(
modifier = Modifier horizontalArrangement = Arrangement.spacedBy(4.dp),
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(text = stringResource(R.string.auto_queue_description)) Text(text = stringResource(R.string.auto_queue_toggle))
Switch(
Row( checked = isAutoQueueEnabled,
horizontalArrangement = Arrangement.spacedBy(4.dp), onCheckedChange = {
verticalAlignment = Alignment.CenterVertically isAutoQueueEnabled = it
) { sharedPreferences.edit {
Text(text = stringResource(R.string.auto_queue_toggle)) putBoolean(key, it)
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)
) )
} }
} }

View file

@ -96,6 +96,10 @@ enum class EmptyStateSpec(
emojiText = "¯\\_(╹x╹)_/¯", emojiText = "¯\\_(╹x╹)_/¯",
descriptionText = R.string.error_unable_to_load_comments, descriptionText = R.string.error_unable_to_load_comments,
), ),
ErrorLoadingItems(
emojiText = "¯\\_(╹x╹)_/¯",
descriptionText = R.string.error_unable_to_load_items,
),
NoSearchResult( NoSearchResult(
emojiText = "╰(°●°╰)", emojiText = "╰(°●°╰)",
descriptionText = R.string.search_no_results, descriptionText = R.string.search_no_results,

View file

@ -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<PlaylistScreenInfo>,
streamFlow: Flow<PagingData<StreamInfoItem>>
) {
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)
}
}
}

View file

@ -9,13 +9,13 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentActivity;
@ -119,7 +119,7 @@ public final class NavigationHelper {
} }
/* PLAY */ /* PLAY */
public static void playOnMainPlayer(final AppCompatActivity activity, public static void playOnMainPlayer(final FragmentActivity activity,
@NonNull final PlayQueue playQueue) { @NonNull final PlayQueue playQueue) {
final PlayQueueItem item = playQueue.getItem(); final PlayQueueItem item = playQueue.getItem();
if (item != null) { if (item != null) {
@ -504,8 +504,13 @@ public final class NavigationHelper {
public static void openPlaylistFragment(final FragmentManager fragmentManager, public static void openPlaylistFragment(final FragmentManager fragmentManager,
final int serviceId, final String url, final int serviceId, final String url,
@NonNull final String name) { @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) defaultTransaction(fragmentManager)
.replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name)) .replace(R.id.fragment_holder, PlaylistFragment.class, args)
.addToBackStack(null) .addToBackStack(null)
.commit(); .commit();
} }

View file

@ -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;
/**
* <p>Class to ellipsize text inside a {@link TextView}.</p>
* 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<Boolean> stateChangeListener = null;
@Nullable private Consumer<Boolean> 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<Boolean> 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<View> 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 <em>ellipsized</em> to <em>full</em> or vice versa.
* @param listener The listener to be called, or {@code null} to remove it.
* The Boolean parameter is the new state.
* <em>Ellipsized</em> content is represented as {@code true},
* normal or <em>full</em> content by {@code false}.
*/
public void setStateChangeListener(@Nullable final Consumer<Boolean> listener) {
this.stateChangeListener = listener;
}
private void notifyStateChangeListener(final boolean oldState) {
if (oldState != isEllipsized && stateChangeListener != null) {
stateChangeListener.accept(isEllipsized);
}
}
}

View file

@ -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<Int>(KEY_SERVICE_ID)!!
val url = savedStateHandle.get<String>(KEY_URL)!!
val playlistTitle = savedStateHandle.get<String>(KEY_TITLE)!!
private val _uiState = MutableStateFlow<Resource<PlaylistScreenInfo>>(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<Resource.Success<PlaylistScreenInfo>>()
.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<Resource.Success<PlaylistScreenInfo>>()
.firstOrNull()
?.data
if (info != null) {
val entity = PlaylistRemoteEntity(info)
remotePlaylistDao.upsert(entity)
}
}
}
}
}

View file

@ -871,6 +871,8 @@
<string name="migration_info_6_7_message">SoundCloud has discontinued the original Top 50 charts. The corresponding tab has been removed from your main page.</string> <string name="migration_info_6_7_message">SoundCloud has discontinued the original Top 50 charts. The corresponding tab has been removed from your main page.</string>
<string name="auto_queue_description">Next</string> <string name="auto_queue_description">Next</string>
<string name="newpipe_extractor_description">NewPipeExtractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently.</string> <string name="newpipe_extractor_description">NewPipeExtractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently.</string>
<string name="error_unable_to_load_items">Could not load items</string>
<string name="playlist_uploader_icon_description">Playlist uploader icon</string>
<plurals name="comments"> <plurals name="comments">
<item quantity="one">%d comment</item> <item quantity="one">%d comment</item>
<item quantity="other">%d comments</item> <item quantity="other">%d comments</item>

View file

@ -106,7 +106,7 @@ androidx-paging-compose = { group = "androidx.paging", name = "paging-compose",
androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" } androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } 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-rxjava3 = { group = "androidx.room", name = "room-rxjava3", version.ref = "room" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", 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" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }