From aac96cce093ab6ecc6f9cd21e6149650fcb50b99 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Thu, 1 Aug 2024 09:08:06 +0530 Subject: [PATCH] Migrate related items fragment to Jetpack Compose --- app/build.gradle | 16 +- .../list/videos/RelatedItemsFragment.kt | 205 +++--------------- .../java/org/schabi/newpipe/ktx/Bundle.kt | 5 + .../schabi/newpipe/local/feed/FeedFragment.kt | 5 +- .../subscription/SubscriptionFragment.kt | 1 + .../subscription/dialog/FeedGroupDialog.kt | 1 + .../NotificationModeConfigFragment.kt | 2 + .../newpipe/ui/components/items/ItemList.kt | 111 ++++++++++ .../ui/components/items/ItemThumbnail.kt | 83 +++++++ .../items/playlist/PlaylistListItem.kt | 72 ++++++ .../components/items/stream/StreamListItem.kt | 87 ++++++++ .../ui/components/items/stream/StreamMenu.kt | 75 +++++++ .../ui/components/items/stream/StreamUtils.kt | 68 ++++++ .../ui/components/video/RelatedItems.kt | 92 ++++++++ .../java/org/schabi/newpipe/util/Constants.kt | 1 + .../res/layout/fragment_related_items.xml | 70 ------ .../main/res/layout/related_items_header.xml | 33 --- app/src/main/res/values/strings.xml | 1 + build.gradle | 2 +- 19 files changed, 648 insertions(+), 282 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/items/ItemThumbnail.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt delete mode 100644 app/src/main/res/layout/fragment_related_items.xml delete mode 100644 app/src/main/res/layout/related_items_header.xml diff --git a/app/build.gradle b/app/build.gradle index dba278efc17..68e4490f7dd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,6 +9,7 @@ plugins { id "kotlin-parcelize" id "checkstyle" id "org.sonarqube" version "4.0.0.2929" + id "org.jetbrains.kotlin.plugin.compose" version "${kotlin_version}" } android { @@ -104,10 +105,6 @@ android { 'META-INF/COPYRIGHT'] } } - - composeOptions { - kotlinCompilerExtensionVersion = "1.5.3" - } } ext { @@ -267,7 +264,7 @@ dependencies { implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}" // Image loading - implementation 'io.coil-kt:coil:2.7.0' + implementation 'io.coil-kt:coil-compose:2.7.0' // Markdown library for Android implementation "io.noties.markwon:core:${markwonVersion}" @@ -289,10 +286,15 @@ dependencies { implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final" // Jetpack Compose - implementation(platform('androidx.compose:compose-bom:2024.02.01')) - implementation 'androidx.compose.material3:material3' + implementation(platform('androidx.compose:compose-bom:2024.06.00')) + implementation 'androidx.compose.material3:material3:1.3.0-beta05' + implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-beta04' implementation 'androidx.activity:activity-compose' implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.ui:ui-text:1.7.0-beta06' // Needed for parsing HTML to AnnotatedString + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose' + implementation 'androidx.paging:paging-compose:3.3.1' + implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0' /** Debugging **/ // Memory leak detection diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt index e46937ede3d..503179cc103 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt @@ -1,176 +1,41 @@ -package org.schabi.newpipe.fragments.list.videos; - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.ktx.ViewUtils; - -import java.io.Serializable; -import java.util.function.Supplier; - -import io.reactivex.rxjava3.core.Single; - -public class RelatedItemsFragment extends BaseListInfoFragment - implements SharedPreferences.OnSharedPreferenceChangeListener { - private static final String INFO_KEY = "related_info_key"; - - private RelatedItemsInfo relatedItemsInfo; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private RelatedItemsHeaderBinding headerBinding; - - public static RelatedItemsFragment getInstance(final StreamInfo info) { - final RelatedItemsFragment instance = new RelatedItemsFragment(); - instance.setInitialData(info); - return instance; - } - - public RelatedItemsFragment() { - super(UserAction.REQUESTED_STREAM); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_related_items, container, false); - } - - @Override - public void onDestroyView() { - headerBinding = null; - super.onDestroyView(); - } - - @Override - protected Supplier getListHeaderSupplier() { - if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) { - return null; - } - - headerBinding = RelatedItemsHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - - final SharedPreferences pref = PreferenceManager - .getDefaultSharedPreferences(requireContext()); - final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false); - headerBinding.autoplaySwitch.setChecked(autoplay); - headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) -> - PreferenceManager.getDefaultSharedPreferences(requireContext()).edit() - .putBoolean(getString(R.string.auto_queue_key), b).apply()); - - return headerBinding::getRoot; - } - - @Override - protected Single> loadMoreItemsLogic() { - return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage); - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single loadResult(final boolean forceLoad) { - return Single.fromCallable(() -> relatedItemsInfo); - } - - @Override - public void showLoading() { - super.showLoading(); - if (headerBinding != null) { - headerBinding.getRoot().setVisibility(View.INVISIBLE); - } - } - - @Override - public void handleResult(@NonNull final RelatedItemsInfo result) { - super.handleResult(result); - - if (headerBinding != null) { - headerBinding.getRoot().setVisibility(View.VISIBLE); - } - ViewUtils.slideUp(requireView(), 120, 96, 0.06f); - - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void setTitle(final String title) { - // Nothing to do - override parent - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - // Nothing to do - override parent - } - - private void setInitialData(final StreamInfo info) { - super.setInitialData(info.getServiceId(), info.getUrl(), info.getName()); - if (this.relatedItemsInfo == null) { - this.relatedItemsInfo = new RelatedItemsInfo(info); - } - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - outState.putSerializable(INFO_KEY, relatedItemsInfo); - } - - @Override - protected void onRestoreInstanceState(@NonNull final Bundle savedState) { - super.onRestoreInstanceState(savedState); - final Serializable serializable = savedState.getSerializable(INFO_KEY); - if (serializable instanceof RelatedItemsInfo) { - this.relatedItemsInfo = (RelatedItemsInfo) serializable; - } - } - - @Override - public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, - final String key) { - if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) { - headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false)); +package org.schabi.newpipe.fragments.list.videos + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.ktx.serializable +import org.schabi.newpipe.ui.components.video.RelatedItems +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_INFO + +class RelatedItemsFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + RelatedItems(requireArguments().serializable(KEY_INFO)!!) + } + } + } } } - @Override - protected ItemViewMode getItemViewMode() { - ItemViewMode mode = super.getItemViewMode(); - // Only list mode is supported. Either List or card will be used. - if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) { - mode = ItemViewMode.LIST; + companion object { + @JvmStatic + fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply { + arguments = bundleOf(KEY_INFO to info) } - return mode; } } diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt index 61721d5467c..e248b8b6c63 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt @@ -3,7 +3,12 @@ package org.schabi.newpipe.ktx import android.os.Bundle import android.os.Parcelable import androidx.core.os.BundleCompat +import java.io.Serializable inline fun Bundle.parcelableArrayList(key: String?): ArrayList? { return BundleCompat.getParcelableArrayList(this, key, T::class.java) } + +inline fun Bundle.serializable(key: String?): T? { + return BundleCompat.getSerializable(this, key, T::class.java) +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index e8c5b1e3497..b9929130905 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -202,6 +202,7 @@ class FeedFragment : BaseStateFragment() { // Menu // ///////////////////////////////////////////////////////////////////////// + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) @@ -212,6 +213,7 @@ class FeedFragment : BaseStateFragment() { inflater.inflate(R.menu.menu_feed_fragment, menu) } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.menu_item_feed_help) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) @@ -253,7 +255,7 @@ class FeedFragment : BaseStateFragment() { viewModel.getShowFutureItemsFromPreferences() ) - AlertDialog.Builder(context!!) + AlertDialog.Builder(requireContext()) .setTitle(R.string.feed_hide_streams_title) .setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked -> checkedDialogItems[which] = isChecked @@ -267,6 +269,7 @@ class FeedFragment : BaseStateFragment() { .show() } + @Deprecated("Deprecated in Java") override fun onDestroyOptionsMenu() { super.onDestroyOptionsMenu() activity?.supportActionBar?.subtitle = null diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index fe232105913..59bbaee9d22 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -129,6 +129,7 @@ class SubscriptionFragment : BaseStateFragment() { // Menu // //////////////////////////////////////////////////////////////////////// + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt index 41761fb0102..954b872a637 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt @@ -94,6 +94,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return object : Dialog(requireActivity(), theme) { + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (!this@FeedGroupDialog.onBackPressed()) { super.onBackPressed() diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt index 581768c304d..2df3e33b669 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt @@ -77,11 +77,13 @@ class NotificationModeConfigFragment : Fragment() { super.onDestroy() } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.menu_notifications_channels, menu) } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_toggle_all -> { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt new file mode 100644 index 00000000000..5bcb79d51f7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt @@ -0,0 +1,111 @@ +package org.schabi.newpipe.ui.components.items + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager +import androidx.window.core.layout.WindowWidthSizeClass +import my.nanihadesuka.compose.LazyColumnScrollbar +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.info_list.ItemViewMode +import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem +import org.schabi.newpipe.ui.components.items.stream.StreamListItem +import org.schabi.newpipe.util.NavigationHelper + +@Composable +fun ItemList( + items: List, + mode: ItemViewMode = determineItemViewMode(), + listHeader: LazyListScope.() -> Unit = {} +) { + val context = LocalContext.current + val onClick = remember { + { item: InfoItem -> + val fragmentManager = (context as FragmentActivity).supportFragmentManager + if (item is StreamInfoItem) { + NavigationHelper.openVideoDetailFragment( + context, fragmentManager, item.serviceId, item.url, item.name, null, false + ) + } else if (item is PlaylistInfoItem) { + NavigationHelper.openPlaylistFragment( + fragmentManager, item.serviceId, item.url, item.name + ) + } + } + } + + // Handle long clicks for stream items + // TODO: Adjust the menu display depending on where it was triggered + var selectedStream by remember { mutableStateOf(null) } + val onLongClick = remember { + { stream: StreamInfoItem -> + selectedStream = stream + } + } + val onDismissPopup = remember { + { + selectedStream = null + } + } + + if (mode == ItemViewMode.GRID) { + // TODO: Implement grid layout using LazyVerticalGrid and LazyVerticalGridScrollbar. + } else { + // Card or list views + val listState = rememberLazyListState() + + LazyColumnScrollbar(state = listState) { + LazyColumn(state = listState) { + listHeader() + + items(items.size) { + val item = items[it] + + // TODO: Implement card layouts. + if (item is StreamInfoItem) { + val isSelected = selectedStream == item + StreamListItem(item, isSelected, onClick, onLongClick, onDismissPopup) + } else if (item is PlaylistInfoItem) { + PlaylistListItem(item, onClick) + } + } + } + } + } +} + +@Composable +private fun determineItemViewMode(): ItemViewMode { + val listMode = PreferenceManager.getDefaultSharedPreferences(LocalContext.current) + .getString( + stringResource(R.string.list_view_mode_key), + stringResource(R.string.list_view_mode_value) + ) + + return when (listMode) { + stringResource(R.string.list_view_mode_list_key) -> ItemViewMode.LIST + stringResource(R.string.list_view_mode_grid_key) -> ItemViewMode.GRID + stringResource(R.string.list_view_mode_card_key) -> ItemViewMode.CARD + else -> { + // Auto mode - evaluate whether to use Grid based on screen real estate. + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) { + ItemViewMode.GRID + } else { + ItemViewMode.LIST + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemThumbnail.kt new file mode 100644 index 00000000000..58b39955150 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemThumbnail.kt @@ -0,0 +1,83 @@ +package org.schabi.newpipe.ui.components.items + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.image.ImageStrategy + +@Composable +fun ItemThumbnail( + item: InfoItem, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit +) { + Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) { + AsyncImage( + model = ImageStrategy.choosePreferredImage(item.thumbnails), + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_thumbnail_video), + error = painterResource(R.drawable.placeholder_thumbnail_video), + contentScale = contentScale, + modifier = modifier + ) + + val isLive = item is StreamInfoItem && StreamTypeUtil.isLiveStream(item.streamType) + val background = if (isLive) Color.Red else Color.Black + val nestedModifier = Modifier + .padding(2.dp) + .background(background.copy(alpha = 0.5f)) + .padding(2.dp) + + if (item is StreamInfoItem) { + Text( + text = if (isLive) { + stringResource(R.string.duration_live) + } else { + Localization.getDurationString(item.duration) + }, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + modifier = nestedModifier + ) + } else if (item is PlaylistInfoItem) { + Row(modifier = nestedModifier, verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.ic_playlist_play), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier.size(18.dp) + ) + + val context = LocalContext.current + Text( + text = Localization.localizeStreamCountMini(context, item.streamCount), + color = Color.White, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt new file mode 100644 index 00000000000..0cc8e68dd0f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt @@ -0,0 +1,72 @@ +package org.schabi.newpipe.ui.components.items.playlist + +import android.content.res.Configuration +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.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.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.ui.components.items.ItemThumbnail +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.NO_SERVICE_ID + +@Composable +fun PlaylistListItem( + playlist: PlaylistInfoItem, + onClick: (InfoItem) -> Unit = {}, +) { + Row( + modifier = Modifier + .clickable { onClick(playlist) } + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ItemThumbnail( + item = playlist, + modifier = Modifier.size(width = 98.dp, height = 55.dp) + ) + + Column { + Text( + text = playlist.name, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + maxLines = 1 + ) + + Text( + text = playlist.uploaderName.orEmpty(), + 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 PlaylistListItemPreview() { + val playlist = PlaylistInfoItem(NO_SERVICE_ID, "", "Playlist") + playlist.uploaderName = "Uploader" + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PlaylistListItem(playlist) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt new file mode 100644 index 00000000000..12cb1245494 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt @@ -0,0 +1,87 @@ +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.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.Alignment +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.dp +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.components.items.ItemThumbnail +import org.schabi.newpipe.ui.theme.AppTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StreamListItem( + stream: StreamInfoItem, + isSelected: Boolean = false, + onClick: (StreamInfoItem) -> Unit = {}, + onLongClick: (StreamInfoItem) -> Unit = {}, + onDismissPopup: () -> Unit = {} +) { + Box { + Row( + modifier = Modifier + .combinedClickable( + onLongClick = { onLongClick(stream) }, + onClick = { onClick(stream) } + ) + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ItemThumbnail( + item = stream, + modifier = Modifier.size(width = 98.dp, height = 55.dp) + ) + + Column { + Text( + text = stream.name, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + maxLines = 1 + ) + + Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall) + + Text( + text = getStreamInfoDetail(stream), + style = MaterialTheme.typography.bodySmall + ) + } + } + + if (isSelected) { + StreamMenu(stream, onDismissPopup) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamListItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamListItem(stream) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt new file mode 100644 index 00000000000..a2c84647d60 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt @@ -0,0 +1,75 @@ +package org.schabi.newpipe.ui.components.items.stream + +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.SparseItemUtil +import org.schabi.newpipe.util.external_communication.ShareUtils + +@Composable +fun StreamMenu( + stream: StreamInfoItem, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + + // TODO: Implement remaining click actions + DropdownMenu(expanded = true, onDismissRequest = onDismissRequest) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.start_here_on_background)) }, + onClick = onDismissRequest + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.start_here_on_popup)) }, + onClick = onDismissRequest + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.download)) }, + onClick = { + onDismissRequest() + SparseItemUtil.fetchStreamInfoAndSaveToDatabase( + context, stream.serviceId, stream.url + ) { info: StreamInfo -> + // TODO: Use an AlertDialog composable instead. + val downloadDialog = DownloadDialog(context, info) + val fragmentManager = (context as FragmentActivity).supportFragmentManager + downloadDialog.show(fragmentManager, "downloadDialog") + } + } + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.add_to_playlist)) }, + onClick = onDismissRequest + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.share)) }, + onClick = { + onDismissRequest() + ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails) + } + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.open_in_browser)) }, + onClick = { + onDismissRequest() + ShareUtils.openUrlInBrowser(context, stream.url) + } + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.mark_as_watched)) }, + onClick = onDismissRequest + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.show_channel_details)) }, + onClick = onDismissRequest + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt new file mode 100644 index 00000000000..cdfe613edf3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt @@ -0,0 +1,68 @@ +package org.schabi.newpipe.ui.components.items.stream + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NO_SERVICE_ID +import java.util.concurrent.TimeUnit + +fun StreamInfoItem( + serviceId: Int = NO_SERVICE_ID, + url: String = "", + name: String = "Stream", + streamType: StreamType, + uploaderName: String? = "Uploader", + uploaderUrl: String? = null, + uploaderAvatars: List = emptyList(), + duration: Long = TimeUnit.HOURS.toSeconds(1), + viewCount: Long = 10, + textualUploadDate: String = "1 month ago" +) = StreamInfoItem(serviceId, url, name, streamType).apply { + this.uploaderName = uploaderName + this.uploaderUrl = uploaderUrl + this.uploaderAvatars = uploaderAvatars + this.duration = duration + this.viewCount = viewCount + this.textualUploadDate = textualUploadDate +} + +@Composable +internal fun getStreamInfoDetail(stream: StreamInfoItem): String { + val context = LocalContext.current + + return rememberSaveable(stream) { + val count = stream.viewCount + val views = if (count >= 0) { + when (stream.streamType) { + StreamType.AUDIO_LIVE_STREAM -> Localization.listeningCount(context, count) + StreamType.LIVE_STREAM -> Localization.shortWatchingCount(context, count) + else -> Localization.shortViewCount(context, count) + } + } else { + "" + } + val date = + Localization.relativeTimeOrTextual(context, stream.uploadDate, stream.textualUploadDate) + + if (views.isEmpty()) { + date.orEmpty() + } else if (date.isNullOrEmpty()) { + views + } else { + "$views • $date" + } + } +} + +internal class StreamItemPreviewProvider : PreviewParameterProvider { + override val values = sequenceOf( + StreamInfoItem(streamType = StreamType.NONE), + StreamInfoItem(streamType = StreamType.LIVE_STREAM), + StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM), + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt new file mode 100644 index 00000000000..d92d7e74a81 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt @@ -0,0 +1,92 @@ +package org.schabi.newpipe.ui.components.video + +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.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.preference.PreferenceManager +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.ui.components.items.ItemList +import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.NO_SERVICE_ID + +@Composable +fun RelatedItems(info: StreamInfo) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(LocalContext.current) + val key = stringResource(R.string.auto_queue_key) + // TODO: AndroidX DataStore might be a better option. + var isAutoQueueEnabled by rememberSaveable { + mutableStateOf(sharedPreferences.getBoolean(key, false)) + } + + ItemList( + items = info.relatedItems, + listHeader = { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(R.string.auto_queue_description)) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.auto_queue_toggle)) + Switch( + checked = isAutoQueueEnabled, + onCheckedChange = { + isAutoQueueEnabled = it + sharedPreferences.edit { + putBoolean(key, it) + } + } + ) + } + } + } + } + ) +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RelatedItemsPreview() { + val info = StreamInfo(NO_SERVICE_ID, "", "", StreamType.VIDEO_STREAM, "", "", 0) + info.relatedItems = listOf( + StreamInfoItem(streamType = StreamType.NONE), + StreamInfoItem(streamType = StreamType.LIVE_STREAM), + StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM), + ) + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + RelatedItems(info) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/Constants.kt b/app/src/main/java/org/schabi/newpipe/util/Constants.kt index 054aadd7078..2160272912a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Constants.kt +++ b/app/src/main/java/org/schabi/newpipe/util/Constants.kt @@ -9,6 +9,7 @@ const val DEFAULT_THROTTLE_TIMEOUT = 120L const val KEY_SERVICE_ID = "key_service_id" const val KEY_URL = "key_url" +const val KEY_INFO = "info" const val KEY_TITLE = "key_title" const val KEY_LINK_TYPE = "key_link_type" const val KEY_OPEN_SEARCH = "key_open_search" diff --git a/app/src/main/res/layout/fragment_related_items.xml b/app/src/main/res/layout/fragment_related_items.xml deleted file mode 100644 index 3591cdfd196..00000000000 --- a/app/src/main/res/layout/fragment_related_items.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/related_items_header.xml b/app/src/main/res/layout/related_items_header.xml deleted file mode 100644 index b50a8484894..00000000000 --- a/app/src/main/res/layout/related_items_header.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bff35e5d9ea..938a2497d00 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -855,4 +855,5 @@ Show more Show less The settings in the export being imported use a vulnerable format that was deprecated since NewPipe 0.27.0. Make sure the export being imported is from a trusted source, and prefer using only exports obtained from NewPipe 0.27.0 or newer in the future. Support for importing settings in this vulnerable format will soon be removed completely, and then old versions of NewPipe will not be able to import settings of exports from new versions anymore. + Next diff --git a/build.gradle b/build.gradle index 6d19a6f8a84..1acfb6f4a2f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.9.10' + ext.kotlin_version = '2.0.0' repositories { google() mavenCentral()