mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-04-17 20:31:23 +00:00
Merge branch 'refs/heads/refactor' into Comments-Compose
# Conflicts: # app/build.gradle
This commit is contained in:
@@ -8,6 +8,7 @@ import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
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>>
|
||||
|
||||
@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")
|
||||
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
@@ -12,9 +15,7 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
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 io.reactivex.rxjava3.core.Maybe;
|
||||
|
||||
@Dao
|
||||
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")
|
||||
Flowable<List<StreamStateEntity>> getState(long streamId);
|
||||
Maybe<StreamStateEntity> getState(long streamId);
|
||||
|
||||
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :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>
|
||||
*/
|
||||
public enum StreamDialogDefaultEntry {
|
||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
|
||||
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
|
||||
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
|
||||
),
|
||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
|
||||
final var activity = fragment.requireActivity();
|
||||
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
|
||||
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Enqueues the stream automatically to the current PlayerType.
|
||||
|
||||
@@ -64,8 +64,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
StreamStateEntity state2 = null;
|
||||
if (DependentPreferenceHelper
|
||||
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||
state2 = historyRecordManager.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
|
||||
}
|
||||
if (state2 != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
@@ -120,7 +119,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||
state = historyRecordManager
|
||||
.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
.blockingGet();
|
||||
}
|
||||
if (state != null && item.getDuration() > 0
|
||||
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||
|
||||
@@ -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 <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@ package org.schabi.newpipe.local.history;
|
||||
* 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.SharedPreferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.collection.LongLongPair;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
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.local.feed.FeedViewModel;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -91,47 +93,39 @@ public class HistoryRecordManager {
|
||||
* @param info the item to mark as watched
|
||||
* @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()) {
|
||||
return Maybe.empty();
|
||||
return Completable.complete();
|
||||
}
|
||||
|
||||
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
|
||||
final long streamId;
|
||||
final long duration;
|
||||
// Duration will not exist if the item was loaded with fast mode, so fetch it if empty
|
||||
if (info.getDuration() < 0) {
|
||||
final StreamInfo completeInfo = ExtractorHelper.getStreamInfo(
|
||||
info.getServiceId(),
|
||||
info.getUrl(),
|
||||
false
|
||||
final var remoteInfo = getStreamInfo(info.getServiceId(), info.getUrl(), false)
|
||||
.map(item ->
|
||||
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item))));
|
||||
|
||||
return Single.just(info)
|
||||
.filter(item -> item.getDuration() >= 0)
|
||||
.map(item ->
|
||||
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item)))
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.blockingGet();
|
||||
duration = completeInfo.getDuration();
|
||||
streamId = streamTable.upsert(new StreamEntity(completeInfo));
|
||||
} else {
|
||||
duration = info.getDuration();
|
||||
streamId = streamTable.upsert(new StreamEntity(info));
|
||||
}
|
||||
.switchIfEmpty(remoteInfo)
|
||||
.flatMapCompletable(pair -> Completable.fromRunnable(() -> {
|
||||
final long duration = pair.getFirst();
|
||||
final long streamId = pair.getSecond();
|
||||
|
||||
// Update the stream progress to the full duration of the video
|
||||
final StreamStateEntity entity = new StreamStateEntity(
|
||||
streamId,
|
||||
duration * 1000
|
||||
);
|
||||
streamStateTable.upsert(entity);
|
||||
// Update the stream progress to the full duration of the video
|
||||
final var entity = new StreamStateEntity(streamId, duration * 1000);
|
||||
streamStateTable.upsert(entity);
|
||||
|
||||
// Add a history entry
|
||||
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||
if (latestEntry == null) {
|
||||
// never actually viewed: add history entry but with 0 views
|
||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
|
||||
} else {
|
||||
return 0L;
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
// Add a history entry
|
||||
final var latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||
if (latestEntry == null) {
|
||||
final var currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
// never actually viewed: add history entry but with 0 views
|
||||
final var entry = new StreamHistoryEntity(streamId, currentTime, 0);
|
||||
streamHistoryTable.insert(entry);
|
||||
}
|
||||
}))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Maybe<Long> onViewed(final StreamInfo info) {
|
||||
@@ -221,7 +215,7 @@ public class HistoryRecordManager {
|
||||
public Flowable<List<String>> getRelatedSearches(final String query,
|
||||
final int similarQueryLimit,
|
||||
final int uniqueQueryLimit) {
|
||||
return query.length() > 0
|
||||
return !query.isEmpty()
|
||||
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
||||
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
||||
}
|
||||
@@ -236,47 +230,31 @@ public class HistoryRecordManager {
|
||||
|
||||
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
|
||||
return queueItem.getStream()
|
||||
.map(info -> streamTable.upsert(new StreamEntity(info)))
|
||||
.flatMapPublisher(streamStateTable::getState)
|
||||
.firstElement()
|
||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||
.flatMapMaybe(this::loadStreamState)
|
||||
.filter(state -> state.isValid(queueItem.getDuration()))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
|
||||
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
|
||||
.flatMapPublisher(streamStateTable::getState)
|
||||
.firstElement()
|
||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||
.filter(state -> state.isValid(info.getDuration()))
|
||||
.flatMapMaybe(streamStateTable::getState)
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
|
||||
return Completable.fromAction(() -> database.runInTransaction(() -> {
|
||||
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())) {
|
||||
streamStateTable.upsert(state);
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
|
||||
return Single.fromCallable(() -> {
|
||||
final List<StreamEntity> entities = streamTable
|
||||
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
|
||||
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 Maybe<StreamStateEntity> loadStreamState(final InfoItem info) {
|
||||
return streamTable.getStream(info.getServiceId(), info.getUrl())
|
||||
.flatMap(entity -> streamStateTable.getState(entity.getUid()))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
|
||||
@@ -295,13 +273,7 @@ public class HistoryRecordManager {
|
||||
result.add(null);
|
||||
continue;
|
||||
}
|
||||
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
|
||||
.blockingFirst();
|
||||
if (states.isEmpty()) {
|
||||
result.add(null);
|
||||
} else {
|
||||
result.add(states.get(0));
|
||||
}
|
||||
result.add(streamStateTable.getState(streamId).blockingGet());
|
||||
}
|
||||
return result;
|
||||
}).subscribeOn(Schedulers.io());
|
||||
|
||||
@@ -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_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"
|
||||
|
||||
@@ -471,13 +471,12 @@ public final class NavigationHelper {
|
||||
.commit();
|
||||
}
|
||||
|
||||
public static void openChannelFragment(@NonNull final Fragment fragment,
|
||||
public static void openChannelFragment(@NonNull final FragmentActivity activity,
|
||||
@NonNull final StreamInfoItem item,
|
||||
final String uploaderUrl) {
|
||||
// For some reason `getParentFragmentManager()` doesn't work, but this does.
|
||||
openChannelFragment(
|
||||
fragment.requireActivity().getSupportFragmentManager(),
|
||||
item.getServiceId(), uploaderUrl, item.getUploaderName());
|
||||
openChannelFragment(activity.getSupportFragmentManager(), 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user