mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2024-11-22 02:53:09 +01:00
Migrate related items fragment to Jetpack Compose (#11383)
* Rename .java to .kt * Migrate related items fragment to Jetpack Compose * Specify mode parameter explicitly * Rm unused class * Fix list item size * Added stream progress bar, separate stream and playlist thumbnails * Display message if no related streams are available * Dispose of related items when closing the video player * Add modifiers for no items message function * Implement remaining stream menu items * Improved stream composables * Use view model lifecycle scope * Make live color solid red * Use nested scroll modifier * Simplify determineItemViewMode()
This commit is contained in:
parent
9d04a73c85
commit
2836191fb3
@ -9,6 +9,7 @@ plugins {
|
|||||||
id "kotlin-parcelize"
|
id "kotlin-parcelize"
|
||||||
id "checkstyle"
|
id "checkstyle"
|
||||||
id "org.sonarqube" version "4.0.0.2929"
|
id "org.sonarqube" version "4.0.0.2929"
|
||||||
|
id "org.jetbrains.kotlin.plugin.compose" version "${kotlin_version}"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -104,10 +105,6 @@ android {
|
|||||||
'META-INF/COPYRIGHT']
|
'META-INF/COPYRIGHT']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = "1.5.3"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
@ -267,7 +264,7 @@ dependencies {
|
|||||||
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation 'io.coil-kt:coil:2.7.0'
|
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||||
|
|
||||||
// Markdown library for Android
|
// Markdown library for Android
|
||||||
implementation "io.noties.markwon:core:${markwonVersion}"
|
implementation "io.noties.markwon:core:${markwonVersion}"
|
||||||
@ -289,10 +286,18 @@ dependencies {
|
|||||||
implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final"
|
implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final"
|
||||||
|
|
||||||
// Jetpack Compose
|
// Jetpack Compose
|
||||||
implementation(platform('androidx.compose:compose-bom:2024.02.01'))
|
implementation(platform('androidx.compose:compose-bom:2024.06.00'))
|
||||||
implementation 'androidx.compose.material3:material3'
|
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.activity:activity-compose'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
|
implementation 'androidx.compose.ui:ui-text:1.7.0-beta07' // Needed for parsing HTML to AnnotatedString
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose'
|
||||||
|
implementation 'androidx.paging:paging-compose:3.3.2'
|
||||||
|
implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0'
|
||||||
|
|
||||||
|
// Coroutines interop
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.8.1'
|
||||||
|
|
||||||
/** Debugging **/
|
/** Debugging **/
|
||||||
// Memory leak detection
|
// Memory leak detection
|
||||||
|
@ -8,6 +8,7 @@ import androidx.room.Query
|
|||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import io.reactivex.rxjava3.core.Completable
|
import io.reactivex.rxjava3.core.Completable
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
|
import io.reactivex.rxjava3.core.Maybe
|
||||||
import org.schabi.newpipe.database.BasicDAO
|
import org.schabi.newpipe.database.BasicDAO
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||||
@ -27,7 +28,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
|||||||
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
|
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
|
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||||
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
|
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>
|
||||||
|
|
||||||
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
|
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
|
||||||
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
|
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
package org.schabi.newpipe.database.stream.dao;
|
package org.schabi.newpipe.database.stream.dao;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
|
||||||
|
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||||
|
|
||||||
import androidx.room.Dao;
|
import androidx.room.Dao;
|
||||||
import androidx.room.Insert;
|
import androidx.room.Insert;
|
||||||
import androidx.room.OnConflictStrategy;
|
import androidx.room.OnConflictStrategy;
|
||||||
@ -12,9 +15,7 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import io.reactivex.rxjava3.core.Flowable;
|
import io.reactivex.rxjava3.core.Flowable;
|
||||||
|
import io.reactivex.rxjava3.core.Maybe;
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
|
public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
|
||||||
@ -32,7 +33,7 @@ public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||||
Flowable<List<StreamStateEntity>> getState(long streamId);
|
Maybe<StreamStateEntity> getState(long streamId);
|
||||||
|
|
||||||
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||||
int deleteState(long streamId);
|
int deleteState(long streamId);
|
||||||
|
@ -1,176 +0,0 @@
|
|||||||
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<InfoItem, RelatedItemsInfo>
|
|
||||||
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<View> 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<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
|
||||||
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Contract
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Single<RelatedItemsInfo> 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,43 @@
|
|||||||
|
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.compose.ui.platform.ViewCompositionStrategy
|
||||||
|
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 {
|
||||||
|
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||||
|
setContent {
|
||||||
|
AppTheme {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
|
||||||
|
arguments = bundleOf(KEY_INFO to info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +0,0 @@
|
|||||||
package org.schabi.newpipe.fragments.list.videos;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.ListInfo;
|
|
||||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
|
|
||||||
public final class RelatedItemsInfo extends ListInfo<InfoItem> {
|
|
||||||
/**
|
|
||||||
* This class is used to wrap the related items of a StreamInfo into a ListInfo object.
|
|
||||||
*
|
|
||||||
* @param info the stream info from which to get related items
|
|
||||||
*/
|
|
||||||
public RelatedItemsInfo(final StreamInfo info) {
|
|
||||||
super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(),
|
|
||||||
info.getId(), Collections.emptyList(), null), info.getName());
|
|
||||||
setRelatedItems(new ArrayList<>(info.getRelatedItems()));
|
|
||||||
}
|
|
||||||
}
|
|
@ -41,10 +41,11 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public enum StreamDialogDefaultEntry {
|
public enum StreamDialogDefaultEntry {
|
||||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
|
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
|
||||||
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
|
final var activity = fragment.requireActivity();
|
||||||
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
|
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
|
||||||
),
|
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueues the stream automatically to the current PlayerType.
|
* Enqueues the stream automatically to the current PlayerType.
|
||||||
|
@ -64,8 +64,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
StreamStateEntity state2 = null;
|
StreamStateEntity state2 = null;
|
||||||
if (DependentPreferenceHelper
|
if (DependentPreferenceHelper
|
||||||
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||||
state2 = historyRecordManager.loadStreamState(infoItem)
|
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
|
||||||
.blockingGet()[0];
|
|
||||||
}
|
}
|
||||||
if (state2 != null) {
|
if (state2 != null) {
|
||||||
itemProgressView.setVisibility(View.VISIBLE);
|
itemProgressView.setVisibility(View.VISIBLE);
|
||||||
@ -120,7 +119,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||||
state = historyRecordManager
|
state = historyRecordManager
|
||||||
.loadStreamState(infoItem)
|
.loadStreamState(infoItem)
|
||||||
.blockingGet()[0];
|
.blockingGet();
|
||||||
}
|
}
|
||||||
if (state != null && item.getDuration() > 0
|
if (state != null && item.getDuration() > 0
|
||||||
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||||
|
@ -3,7 +3,12 @@ package org.schabi.newpipe.ktx
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.core.os.BundleCompat
|
import androidx.core.os.BundleCompat
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||||
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
|
||||||
|
return BundleCompat.getSerializable(this, key, T::class.java)
|
||||||
|
}
|
||||||
|
@ -202,6 +202,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
// Menu
|
// Menu
|
||||||
// /////////////////////////////////////////////////////////////////////////
|
// /////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
super.onCreateOptionsMenu(menu, inflater)
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
|
||||||
@ -212,6 +213,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
if (item.itemId == R.id.menu_item_feed_help) {
|
if (item.itemId == R.id.menu_item_feed_help) {
|
||||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
@ -253,7 +255,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
viewModel.getShowFutureItemsFromPreferences()
|
viewModel.getShowFutureItemsFromPreferences()
|
||||||
)
|
)
|
||||||
|
|
||||||
AlertDialog.Builder(context!!)
|
AlertDialog.Builder(requireContext())
|
||||||
.setTitle(R.string.feed_hide_streams_title)
|
.setTitle(R.string.feed_hide_streams_title)
|
||||||
.setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked ->
|
.setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked ->
|
||||||
checkedDialogItems[which] = isChecked
|
checkedDialogItems[which] = isChecked
|
||||||
@ -267,6 +269,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onDestroyOptionsMenu() {
|
override fun onDestroyOptionsMenu() {
|
||||||
super.onDestroyOptionsMenu()
|
super.onDestroyOptionsMenu()
|
||||||
activity?.supportActionBar?.subtitle = null
|
activity?.supportActionBar?.subtitle = null
|
||||||
|
@ -18,10 +18,13 @@ package org.schabi.newpipe.local.history;
|
|||||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.ExtractorHelper.getStreamInfo;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.collection.LongLongPair;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.NewPipeDatabase;
|
import org.schabi.newpipe.NewPipeDatabase;
|
||||||
@ -45,7 +48,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
|
|||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.local.feed.FeedViewModel;
|
import org.schabi.newpipe.local.feed.FeedViewModel;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
@ -91,47 +93,39 @@ public class HistoryRecordManager {
|
|||||||
* @param info the item to mark as watched
|
* @param info the item to mark as watched
|
||||||
* @return a Maybe containing the ID of the item if successful
|
* @return a Maybe containing the ID of the item if successful
|
||||||
*/
|
*/
|
||||||
public Maybe<Long> markAsWatched(final StreamInfoItem info) {
|
public Completable markAsWatched(final StreamInfoItem info) {
|
||||||
if (!isStreamHistoryEnabled()) {
|
if (!isStreamHistoryEnabled()) {
|
||||||
return Maybe.empty();
|
return Completable.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
final var remoteInfo = getStreamInfo(info.getServiceId(), info.getUrl(), false)
|
||||||
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
|
.map(item ->
|
||||||
final long streamId;
|
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item))));
|
||||||
final long duration;
|
|
||||||
// Duration will not exist if the item was loaded with fast mode, so fetch it if empty
|
return Single.just(info)
|
||||||
if (info.getDuration() < 0) {
|
.filter(item -> item.getDuration() >= 0)
|
||||||
final StreamInfo completeInfo = ExtractorHelper.getStreamInfo(
|
.map(item ->
|
||||||
info.getServiceId(),
|
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item)))
|
||||||
info.getUrl(),
|
|
||||||
false
|
|
||||||
)
|
)
|
||||||
.subscribeOn(Schedulers.io())
|
.switchIfEmpty(remoteInfo)
|
||||||
.blockingGet();
|
.flatMapCompletable(pair -> Completable.fromRunnable(() -> {
|
||||||
duration = completeInfo.getDuration();
|
final long duration = pair.getFirst();
|
||||||
streamId = streamTable.upsert(new StreamEntity(completeInfo));
|
final long streamId = pair.getSecond();
|
||||||
} else {
|
|
||||||
duration = info.getDuration();
|
|
||||||
streamId = streamTable.upsert(new StreamEntity(info));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the stream progress to the full duration of the video
|
// Update the stream progress to the full duration of the video
|
||||||
final StreamStateEntity entity = new StreamStateEntity(
|
final var entity = new StreamStateEntity(streamId, duration * 1000);
|
||||||
streamId,
|
streamStateTable.upsert(entity);
|
||||||
duration * 1000
|
|
||||||
);
|
|
||||||
streamStateTable.upsert(entity);
|
|
||||||
|
|
||||||
// Add a history entry
|
// Add a history entry
|
||||||
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
final var latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||||
if (latestEntry == null) {
|
if (latestEntry == null) {
|
||||||
// never actually viewed: add history entry but with 0 views
|
final var currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
|
// never actually viewed: add history entry but with 0 views
|
||||||
} else {
|
final var entry = new StreamHistoryEntity(streamId, currentTime, 0);
|
||||||
return 0L;
|
streamHistoryTable.insert(entry);
|
||||||
}
|
}
|
||||||
})).subscribeOn(Schedulers.io());
|
}))
|
||||||
|
.subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<Long> onViewed(final StreamInfo info) {
|
public Maybe<Long> onViewed(final StreamInfo info) {
|
||||||
@ -221,7 +215,7 @@ public class HistoryRecordManager {
|
|||||||
public Flowable<List<String>> getRelatedSearches(final String query,
|
public Flowable<List<String>> getRelatedSearches(final String query,
|
||||||
final int similarQueryLimit,
|
final int similarQueryLimit,
|
||||||
final int uniqueQueryLimit) {
|
final int uniqueQueryLimit) {
|
||||||
return query.length() > 0
|
return !query.isEmpty()
|
||||||
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
||||||
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
||||||
}
|
}
|
||||||
@ -236,47 +230,31 @@ public class HistoryRecordManager {
|
|||||||
|
|
||||||
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
|
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
|
||||||
return queueItem.getStream()
|
return queueItem.getStream()
|
||||||
.map(info -> streamTable.upsert(new StreamEntity(info)))
|
.flatMapMaybe(this::loadStreamState)
|
||||||
.flatMapPublisher(streamStateTable::getState)
|
|
||||||
.firstElement()
|
|
||||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
|
||||||
.filter(state -> state.isValid(queueItem.getDuration()))
|
.filter(state -> state.isValid(queueItem.getDuration()))
|
||||||
.subscribeOn(Schedulers.io());
|
.subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
|
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
|
||||||
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
|
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
|
||||||
.flatMapPublisher(streamStateTable::getState)
|
.flatMapMaybe(streamStateTable::getState)
|
||||||
.firstElement()
|
|
||||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
|
||||||
.filter(state -> state.isValid(info.getDuration()))
|
|
||||||
.subscribeOn(Schedulers.io());
|
.subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
|
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
|
||||||
return Completable.fromAction(() -> database.runInTransaction(() -> {
|
return Completable.fromAction(() -> database.runInTransaction(() -> {
|
||||||
final long streamId = streamTable.upsert(new StreamEntity(info));
|
final long streamId = streamTable.upsert(new StreamEntity(info));
|
||||||
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
|
final var state = new StreamStateEntity(streamId, progressMillis);
|
||||||
if (state.isValid(info.getDuration())) {
|
if (state.isValid(info.getDuration())) {
|
||||||
streamStateTable.upsert(state);
|
streamStateTable.upsert(state);
|
||||||
}
|
}
|
||||||
})).subscribeOn(Schedulers.io());
|
})).subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
|
public Maybe<StreamStateEntity> loadStreamState(final InfoItem info) {
|
||||||
return Single.fromCallable(() -> {
|
return streamTable.getStream(info.getServiceId(), info.getUrl())
|
||||||
final List<StreamEntity> entities = streamTable
|
.flatMap(entity -> streamStateTable.getState(entity.getUid()))
|
||||||
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
|
.subscribeOn(Schedulers.io());
|
||||||
if (entities.isEmpty()) {
|
|
||||||
return new StreamStateEntity[]{null};
|
|
||||||
}
|
|
||||||
final List<StreamStateEntity> states = streamStateTable
|
|
||||||
.getState(entities.get(0).getUid()).blockingFirst();
|
|
||||||
if (states.isEmpty()) {
|
|
||||||
return new StreamStateEntity[]{null};
|
|
||||||
}
|
|
||||||
return new StreamStateEntity[]{states.get(0)};
|
|
||||||
}).subscribeOn(Schedulers.io());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
|
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
|
||||||
@ -295,13 +273,7 @@ public class HistoryRecordManager {
|
|||||||
result.add(null);
|
result.add(null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
|
result.add(streamStateTable.getState(streamId).blockingGet());
|
||||||
.blockingFirst();
|
|
||||||
if (states.isEmpty()) {
|
|
||||||
result.add(null);
|
|
||||||
} else {
|
|
||||||
result.add(states.get(0));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}).subscribeOn(Schedulers.io());
|
}).subscribeOn(Schedulers.io());
|
||||||
|
@ -129,6 +129,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||||||
// Menu
|
// Menu
|
||||||
// ////////////////////////////////////////////////////////////////////////
|
// ////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
super.onCreateOptionsMenu(menu, inflater)
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
|
||||||
|
@ -94,6 +94,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
|
|||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
return object : Dialog(requireActivity(), theme) {
|
return object : Dialog(requireActivity(), theme) {
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
if (!this@FeedGroupDialog.onBackPressed()) {
|
if (!this@FeedGroupDialog.onBackPressed()) {
|
||||||
super.onBackPressed()
|
super.onBackPressed()
|
||||||
|
@ -77,11 +77,13 @@ class NotificationModeConfigFragment : Fragment() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
super.onCreateOptionsMenu(menu, inflater)
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
inflater.inflate(R.menu.menu_notifications_channels, menu)
|
inflater.inflate(R.menu.menu_notifications_channels, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_toggle_all -> {
|
R.id.action_toggle_all -> {
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
package org.schabi.newpipe.ui.components.common
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.ui.theme.AppTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoItemsMessage(@StringRes message: Int) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentSize(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(text = "(╯°-°)╯", fontSize = 35.sp)
|
||||||
|
Text(text = stringResource(id = message), fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||||
|
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
private fun NoItemsMessagePreview() {
|
||||||
|
AppTheme {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
NoItemsMessage(message = R.string.no_videos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,114 @@
|
|||||||
|
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.Modifier
|
||||||
|
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.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.DependentPreferenceHelper
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemList(
|
||||||
|
items: List<InfoItem>,
|
||||||
|
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<StreamInfoItem?>(null) }
|
||||||
|
val onLongClick = remember {
|
||||||
|
{ stream: StreamInfoItem ->
|
||||||
|
selectedStream = stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onDismissPopup = remember {
|
||||||
|
{
|
||||||
|
selectedStream = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context)
|
||||||
|
val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
|
||||||
|
|
||||||
|
if (mode == ItemViewMode.GRID) {
|
||||||
|
// TODO: Implement grid layout using LazyVerticalGrid and LazyVerticalGridScrollbar.
|
||||||
|
} else {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
|
||||||
|
LazyColumnScrollbar(state = state) {
|
||||||
|
LazyColumn(modifier = nestedScrollModifier, state = state) {
|
||||||
|
listHeader()
|
||||||
|
|
||||||
|
items(items.size) {
|
||||||
|
val item = items[it]
|
||||||
|
|
||||||
|
if (item is StreamInfoItem) {
|
||||||
|
val isSelected = selectedStream == item
|
||||||
|
StreamListItem(
|
||||||
|
item, showProgress, isSelected, onClick, onLongClick, onDismissPopup
|
||||||
|
)
|
||||||
|
} else if (item is PlaylistInfoItem) {
|
||||||
|
PlaylistListItem(item, onClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun determineItemViewMode(): ItemViewMode {
|
||||||
|
val prefValue = PreferenceManager.getDefaultSharedPreferences(LocalContext.current)
|
||||||
|
.getString(stringResource(R.string.list_view_mode_key), null)
|
||||||
|
val viewMode = prefValue?.let { ItemViewMode.valueOf(it.uppercase()) } ?: ItemViewMode.AUTO
|
||||||
|
|
||||||
|
return when (viewMode) {
|
||||||
|
ItemViewMode.AUTO -> {
|
||||||
|
// Evaluate whether to use Grid based on screen real estate.
|
||||||
|
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||||
|
if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
|
||||||
|
ItemViewMode.GRID
|
||||||
|
} else {
|
||||||
|
ItemViewMode.LIST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> viewMode
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
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.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
|
||||||
|
) {
|
||||||
|
PlaylistThumbnail(
|
||||||
|
playlist = playlist,
|
||||||
|
modifier = Modifier.size(width = 140.dp, height = 78.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = playlist.name,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
package org.schabi.newpipe.ui.components.items.playlist
|
||||||
|
|
||||||
|
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.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
||||||
|
import org.schabi.newpipe.util.Localization
|
||||||
|
import org.schabi.newpipe.util.image.ImageStrategy
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistThumbnail(
|
||||||
|
playlist: PlaylistInfoItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentScale: ContentScale = ContentScale.Fit
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageStrategy.choosePreferredImage(playlist.thumbnails),
|
||||||
|
contentDescription = null,
|
||||||
|
placeholder = painterResource(R.drawable.placeholder_thumbnail_playlist),
|
||||||
|
error = painterResource(R.drawable.placeholder_thumbnail_playlist),
|
||||||
|
contentScale = contentScale,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.padding(2.dp),
|
||||||
|
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, playlist.streamCount),
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
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.theme.AppTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun StreamListItem(
|
||||||
|
stream: StreamInfoItem,
|
||||||
|
showProgress: Boolean,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: (StreamInfoItem) -> Unit = {},
|
||||||
|
onLongClick: (StreamInfoItem) -> Unit = {},
|
||||||
|
onDismissPopup: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
// Box serves as an anchor for the dropdown menu
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.combinedClickable(onLongClick = { onLongClick(stream) }, onClick = { onClick(stream) })
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
StreamThumbnail(
|
||||||
|
stream = stream,
|
||||||
|
showProgress = showProgress,
|
||||||
|
modifier = Modifier.size(width = 140.dp, height = 78.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
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 StreamListItemPreview(
|
||||||
|
@PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem
|
||||||
|
) {
|
||||||
|
AppTheme {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
StreamListItem(stream, showProgress = false, isSelected = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,152 @@
|
|||||||
|
package org.schabi.newpipe.ui.components.items.stream
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.download.DownloadDialog
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog
|
||||||
|
import org.schabi.newpipe.local.dialog.PlaylistDialog
|
||||||
|
import org.schabi.newpipe.player.helper.PlayerHolder
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper
|
||||||
|
import org.schabi.newpipe.util.SparseItemUtil
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
import org.schabi.newpipe.viewmodels.StreamViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StreamMenu(
|
||||||
|
stream: StreamInfoItem,
|
||||||
|
expanded: Boolean,
|
||||||
|
onDismissRequest: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val streamViewModel = viewModel<StreamViewModel>()
|
||||||
|
val playerHolder = PlayerHolder.getInstance()
|
||||||
|
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||||
|
if (playerHolder.isPlayQueueReady) {
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.enqueue_stream,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
|
||||||
|
NavigationHelper.enqueueOnPlayer(context, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (playerHolder.queuePosition < playerHolder.queueSize - 1) {
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.enqueue_next_stream,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
|
||||||
|
NavigationHelper.enqueueNextOnPlayer(context, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.start_here_on_background,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
|
||||||
|
NavigationHelper.playOnBackgroundPlayer(context, it, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.start_here_on_popup,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
|
||||||
|
NavigationHelper.playOnPopupPlayer(context, it, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.download,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
SparseItemUtil.fetchStreamInfoAndSaveToDatabase(
|
||||||
|
context, stream.serviceId, stream.url
|
||||||
|
) { info ->
|
||||||
|
// TODO: Use an AlertDialog composable instead.
|
||||||
|
val downloadDialog = DownloadDialog(context, info)
|
||||||
|
val fragmentManager = (context as FragmentActivity).supportFragmentManager
|
||||||
|
downloadDialog.show(fragmentManager, "downloadDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.add_to_playlist,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
val list = listOf(StreamEntity(stream))
|
||||||
|
PlaylistDialog.createCorrespondingDialog(context, list) { dialog ->
|
||||||
|
val tag = if (dialog is PlaylistAppendDialog) "append" else "create"
|
||||||
|
dialog.show(
|
||||||
|
(context as FragmentActivity).supportFragmentManager,
|
||||||
|
"StreamDialogEntry@${tag}_playlist"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.share,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.open_in_browser,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
ShareUtils.openUrlInBrowser(context, stream.url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.mark_as_watched,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
streamViewModel.markAsWatched(stream)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StreamMenuItem(
|
||||||
|
text = R.string.show_channel_details,
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
SparseItemUtil.fetchUploaderUrlIfSparse(
|
||||||
|
context, stream.serviceId, stream.url, stream.uploaderUrl
|
||||||
|
) { url ->
|
||||||
|
NavigationHelper.openChannelFragment(context as FragmentActivity, stream, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StreamMenuItem(
|
||||||
|
@StringRes text: Int,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(text = stringResource(text), color = MaterialTheme.colorScheme.onBackground)
|
||||||
|
},
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
package org.schabi.newpipe.ui.components.items.stream
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredHeight
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
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
|
||||||
|
import org.schabi.newpipe.viewmodels.StreamViewModel
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StreamThumbnail(
|
||||||
|
stream: StreamInfoItem,
|
||||||
|
showProgress: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentScale: ContentScale = ContentScale.Fit
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageStrategy.choosePreferredImage(stream.thumbnails),
|
||||||
|
contentDescription = null,
|
||||||
|
placeholder = painterResource(R.drawable.placeholder_thumbnail_video),
|
||||||
|
error = painterResource(R.drawable.placeholder_thumbnail_video),
|
||||||
|
contentScale = contentScale,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
|
||||||
|
val isLive = StreamTypeUtil.isLiveStream(stream.streamType)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.background(if (isLive) Color.Red else Color.Black.copy(alpha = 0.5f))
|
||||||
|
.padding(2.dp),
|
||||||
|
text = if (isLive) {
|
||||||
|
stringResource(R.string.duration_live)
|
||||||
|
} else {
|
||||||
|
Localization.getDurationString(stream.duration)
|
||||||
|
},
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showProgress) {
|
||||||
|
val streamViewModel = viewModel<StreamViewModel>()
|
||||||
|
var progress by rememberSaveable { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
LaunchedEffect(stream) {
|
||||||
|
progress = streamViewModel.getStreamState(stream)?.progressMillis ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress != 0L) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.requiredHeight(2.dp),
|
||||||
|
progress = {
|
||||||
|
(progress.milliseconds / stream.duration.seconds).toFloat()
|
||||||
|
},
|
||||||
|
gapSize = 0.dp,
|
||||||
|
drawStopIndicator = {} // Hide stop indicator
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<Image> = 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<StreamInfoItem> {
|
||||||
|
override val values = sequenceOf(
|
||||||
|
StreamInfoItem(streamType = StreamType.NONE),
|
||||||
|
StreamInfoItem(streamType = StreamType.LIVE_STREAM),
|
||||||
|
StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM),
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
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.info_list.ItemViewMode
|
||||||
|
import org.schabi.newpipe.ui.components.common.NoItemsMessage
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.relatedItems.isEmpty()) {
|
||||||
|
NoItemsMessage(message = R.string.no_videos)
|
||||||
|
} else {
|
||||||
|
ItemList(
|
||||||
|
items = info.relatedItems,
|
||||||
|
mode = ItemViewMode.LIST,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@ const val DEFAULT_THROTTLE_TIMEOUT = 120L
|
|||||||
|
|
||||||
const val KEY_SERVICE_ID = "key_service_id"
|
const val KEY_SERVICE_ID = "key_service_id"
|
||||||
const val KEY_URL = "key_url"
|
const val KEY_URL = "key_url"
|
||||||
|
const val KEY_INFO = "info"
|
||||||
const val KEY_TITLE = "key_title"
|
const val KEY_TITLE = "key_title"
|
||||||
const val KEY_LINK_TYPE = "key_link_type"
|
const val KEY_LINK_TYPE = "key_link_type"
|
||||||
const val KEY_OPEN_SEARCH = "key_open_search"
|
const val KEY_OPEN_SEARCH = "key_open_search"
|
||||||
|
@ -472,13 +472,12 @@ public final class NavigationHelper {
|
|||||||
.commit();
|
.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void openChannelFragment(@NonNull final Fragment fragment,
|
public static void openChannelFragment(@NonNull final FragmentActivity activity,
|
||||||
@NonNull final StreamInfoItem item,
|
@NonNull final StreamInfoItem item,
|
||||||
final String uploaderUrl) {
|
final String uploaderUrl) {
|
||||||
// For some reason `getParentFragmentManager()` doesn't work, but this does.
|
// For some reason `getParentFragmentManager()` doesn't work, but this does.
|
||||||
openChannelFragment(
|
openChannelFragment(activity.getSupportFragmentManager(), item.getServiceId(), uploaderUrl,
|
||||||
fragment.requireActivity().getSupportFragmentManager(),
|
item.getUploaderName());
|
||||||
item.getServiceId(), uploaderUrl, item.getUploaderName());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
package org.schabi.newpipe.viewmodels
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.rx3.await
|
||||||
|
import kotlinx.coroutines.rx3.awaitSingleOrNull
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.history.HistoryRecordManager
|
||||||
|
|
||||||
|
class StreamViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val historyRecordManager = HistoryRecordManager(application)
|
||||||
|
|
||||||
|
suspend fun getStreamState(infoItem: InfoItem): StreamStateEntity? {
|
||||||
|
return historyRecordManager.loadStreamState(infoItem).awaitSingleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAsWatched(stream: StreamInfoItem) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
historyRecordManager.markAsWatched(stream).await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,70 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/items_list"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:scrollbars="vertical"
|
|
||||||
tools:listitem="@layout/list_stream_item" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/loading_progress_bar"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_centerInParent="true"
|
|
||||||
android:indeterminate="true"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/empty_state_view"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_centerHorizontal="true"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:paddingTop="85dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:visibility="visible">
|
|
||||||
|
|
||||||
<org.schabi.newpipe.views.NewPipeTextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:layout_marginBottom="10dp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:text="(╯°-°)╯"
|
|
||||||
android:textSize="35sp"
|
|
||||||
tools:ignore="HardcodedText,UnusedAttribute" />
|
|
||||||
|
|
||||||
<org.schabi.newpipe.views.NewPipeTextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:text="@string/empty_view_no_videos"
|
|
||||||
android:textSize="24sp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!--ERROR PANEL-->
|
|
||||||
<include
|
|
||||||
android:id="@+id/error_panel"
|
|
||||||
layout="@layout/error_panel"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_centerInParent="true"
|
|
||||||
android:layout_marginTop="50dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="4dp"
|
|
||||||
android:layout_alignParentTop="true"
|
|
||||||
android:background="?attr/toolbar_shadow"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
</RelativeLayout>
|
|
@ -1,33 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="?attr/selectableItemBackground">
|
|
||||||
|
|
||||||
<org.schabi.newpipe.views.NewPipeTextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="16dp"
|
|
||||||
android:layout_marginEnd="16dp"
|
|
||||||
android:text="@string/exo_controls_next_description"
|
|
||||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
|
||||||
android:textSize="13sp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/autoplay_switch"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.appcompat.widget.SwitchCompat
|
|
||||||
android:id="@+id/autoplay_switch"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="16dp"
|
|
||||||
android:text="@string/auto_queue_toggle"
|
|
||||||
android:textColor="@android:color/tab_indicator_text"
|
|
||||||
android:textSize="13sp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -855,4 +855,5 @@
|
|||||||
<string name="show_more">Show more</string>
|
<string name="show_more">Show more</string>
|
||||||
<string name="show_less">Show less</string>
|
<string name="show_less">Show less</string>
|
||||||
<string name="import_settings_vulnerable_format">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.</string>
|
<string name="import_settings_vulnerable_format">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.</string>
|
||||||
|
<string name="auto_queue_description">Next</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.9.10'
|
ext.kotlin_version = '2.0.0'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
Loading…
Reference in New Issue
Block a user