mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2025-10-03 09:49:21 +02:00
Merge d9a64d9bf0
into abfde872f1
This commit is contained in:
commit
d3c25ff439
30 changed files with 1087 additions and 1011 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -101,7 +101,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
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,10 +134,11 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
menuProvider = new 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) {
|
||||
|
@ -148,7 +148,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -193,12 +192,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
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);
|
||||
activity.addMenuProvider(menuProvider, getViewLifecycleOwner());
|
||||
|
||||
setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported);
|
||||
|
||||
|
@ -235,14 +229,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
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<ChannelInfo>
|
|||
}
|
||||
disposables.clear();
|
||||
binding = null;
|
||||
menuProvider = 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<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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
/**
|
||||
|
|
|
@ -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<List<PlaylistL
|
|||
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<InfoItem>,
|
||||
items: LazyPagingItems<out InfoItem>,
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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,14 +43,11 @@ 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(start = 12.dp, end = 12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
@ -58,7 +55,7 @@ fun RelatedItems(info: StreamInfo) {
|
|||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = stringResource(R.string.auto_queue_toggle))
|
||||
Switch(
|
||||
|
@ -73,17 +70,6 @@ fun RelatedItems(info: StreamInfo) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (info.relatedItems.isEmpty()) {
|
||||
item {
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.NoVideos,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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="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="error_unable_to_load_items">Could not load items</string>
|
||||
<string name="playlist_uploader_icon_description">Playlist uploader icon</string>
|
||||
<plurals name="comments">
|
||||
<item quantity="one">%d comment</item>
|
||||
<item quantity="other">%d comments</item>
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue