Merge pull request #9182 from Theta-Dev/channel-tabs

Add support for channel tabs
This commit is contained in:
Stypox 2023-09-18 23:46:13 +02:00 committed by GitHub
commit 0eae9e7cdc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1796 additions and 907 deletions

View file

@ -197,7 +197,7 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test // name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/ // This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:340095515d45ecbee576872c7198992ebd8e4f08' implementation 'com.github.TeamNewPipe:NewPipeExtractor:95a3cc0a173bba28c179f9f9503b1010ec6bff21'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/ /** Checkstyle **/

View file

@ -10,19 +10,13 @@ import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.testUtil.TestDatabase; import org.schabi.newpipe.testUtil.TestDatabase;
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule; import org.schabi.newpipe.testUtil.TrampolineSchedulerRule;
import java.io.IOException; import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List; import java.util.List;
public class SubscriptionManagerTest { public class SubscriptionManagerTest {
@ -58,7 +52,7 @@ public class SubscriptionManagerTest {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown"); final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown");
final SubscriptionEntity subscription = SubscriptionEntity.from(info); final SubscriptionEntity subscription = SubscriptionEntity.from(info);
manager.insertSubscription(subscription, info); manager.insertSubscription(subscription);
final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity(); final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity();
// the uid has changed, since the uid is chosen upon inserting, but the rest should match // the uid has changed, since the uid is chosen upon inserting, but the rest should match
@ -76,7 +70,7 @@ public class SubscriptionManagerTest {
final SubscriptionEntity subscription = SubscriptionEntity.from(info); final SubscriptionEntity subscription = SubscriptionEntity.from(info);
subscription.setNotificationMode(0); subscription.setNotificationMode(0);
manager.insertSubscription(subscription, info); manager.insertSubscription(subscription);
manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1) manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1)
.blockingAwait(); .blockingAwait();
final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity(); final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity();
@ -85,35 +79,4 @@ public class SubscriptionManagerTest {
assertEquals(subscription.getUrl(), anotherSubscription.getUrl()); assertEquals(subscription.getUrl(), anotherSubscription.getUrl());
assertEquals(1, anotherSubscription.getNotificationMode()); assertEquals(1, anotherSubscription.getNotificationMode());
} }
@Test
public void testRememberRecentStreams() throws ExtractionException, IOException {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/Polyphia");
final List<StreamInfoItem> relatedItems = List.of(
new StreamInfoItem(0, "a", "b", StreamType.VIDEO_STREAM),
new StreamInfoItem(1, "c", "d", StreamType.AUDIO_STREAM),
new StreamInfoItem(2, "e", "f", StreamType.AUDIO_LIVE_STREAM),
new StreamInfoItem(3, "g", "h", StreamType.LIVE_STREAM));
relatedItems.forEach(item -> {
// these two fields must be non-null for the insert to succeed
item.setUploaderUrl(info.getUrl());
item.setUploaderName(info.getName());
// the upload date must not be too much in the past for the item to actually be inserted
item.setUploadDate(new DateWrapper(OffsetDateTime.now()));
});
info.setRelatedItems(relatedItems);
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
manager.insertSubscription(subscription, info);
final List<StreamEntity> streams = database.streamDAO().getAll().blockingFirst();
assertEquals(4, streams.size());
streams.sort(Comparator.comparing(StreamEntity::getServiceId));
for (int i = 0; i < 4; i++) {
assertEquals(relatedItems.get(0).getServiceId(), streams.get(0).getServiceId());
assertEquals(relatedItems.get(0).getUrl(), streams.get(0).getUrl());
assertEquals(relatedItems.get(0).getName(), streams.get(0).getTitle());
assertEquals(relatedItems.get(0).getStreamType(), streams.get(0).getStreamType());
}
}
} }

View file

@ -65,6 +65,7 @@ import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException; import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
@ -72,10 +73,11 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
@ -1022,7 +1024,16 @@ public class RouterActivity extends AppCompatActivity {
} }
playQueue = new SinglePlayQueue((StreamInfo) info); playQueue = new SinglePlayQueue((StreamInfo) info);
} else if (info instanceof ChannelInfo) { } else if (info instanceof ChannelInfo) {
playQueue = new ChannelPlayQueue((ChannelInfo) info); final Optional<ListLinkHandler> playableTab = ((ChannelInfo) info).getTabs()
.stream()
.filter(ChannelTabHelper::isStreamsTab)
.findFirst();
if (playableTab.isPresent()) {
playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get());
} else {
return; // there is no playable tab
}
} else if (info instanceof PlaylistInfo) { } else if (info instanceof PlaylistInfo) {
playQueue = new PlaylistPlayQueue((PlaylistInfo) info); playQueue = new PlaylistPlayQueue((PlaylistInfo) info);
} else { } else {

View file

@ -0,0 +1,209 @@
package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.text.HtmlCompat;
import com.google.android.material.chip.Chip;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
import org.schabi.newpipe.databinding.ItemMetadataBinding;
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.List;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public abstract class BaseDescriptionFragment extends BaseFragment {
private final CompositeDisposable descriptionDisposables = new CompositeDisposable();
protected FragmentDescriptionBinding binding;
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
setupDescription();
setupMetadata(inflater, binding.detailMetadataLayout);
addTagsMetadataItem(inflater, binding.detailMetadataLayout);
return binding.getRoot();
}
@Override
public void onDestroy() {
descriptionDisposables.clear();
super.onDestroy();
}
/**
* Get the description to display.
* @return description object
*/
@Nullable
protected abstract Description getDescription();
/**
* Get the streaming service. Used for generating description links.
* @return streaming service
*/
@Nullable
protected abstract StreamingService getService();
/**
* Get the streaming service ID. Used for tag links.
* @return service ID
*/
protected abstract int getServiceId();
/**
* Get the URL of the described video or audio, used to generate description links.
* @return stream URL
*/
@Nullable
protected abstract String getStreamUrl();
/**
* Get the list of tags to display below the description.
* @return tag list
*/
@Nullable
public abstract List<String> getTags();
/**
* Add additional metadata to display.
* @param inflater LayoutInflater
* @param layout detailMetadataLayout
*/
protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout);
private void setupDescription() {
final Description description = getDescription();
if (description == null || isEmpty(description.getContent())
|| description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return;
}
// start with disabled state. This also loads description content (!)
disableDescriptionSelection();
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
disableDescriptionSelection();
} else {
// enable selection only when button is clicked to prevent flickering
enableDescriptionSelection();
}
});
}
private void enableDescriptionSelection() {
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
binding.detailDescriptionView.setTextIsSelectable(true);
final String buttonLabel = getString(R.string.description_select_disable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
}
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
final Description description = getDescription();
if (description != null) {
TextLinkifier.fromDescription(binding.detailDescriptionView,
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
getService(), getStreamUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
}
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
final String buttonLabel = getString(R.string.description_select_enable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
protected void addMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
final boolean linkifyContent,
@StringRes final int type,
@Nullable final String content) {
if (isBlank(content)) {
return;
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
ShareUtils.copyToClipboard(requireContext(), content);
return true;
});
if (linkifyContent) {
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
final List<String> tags = getTags();
if (tags != null && !tags.isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false);
chip.setText(tag);
chip.setOnClickListener(this::onTagClick);
chip.setOnLongClickListener(this::onTagLongClick);
itemBinding.metadataTagsChips.addView(chip);
});
layout.addView(itemBinding.getRoot());
}
}
private void onTagClick(final View chip) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
getServiceId(), ((Chip) chip).getText().toString());
}
}
private boolean onTagLongClick(final View chip) {
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
return true;
}
}

View file

@ -1,46 +1,29 @@
package org.schabi.newpipe.fragments.detail; package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.getAppLocale; import static org.schabi.newpipe.util.Localization.getAppLocale;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes; import androidx.annotation.StringRes;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.text.HtmlCompat;
import com.google.android.material.chip.Chip;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentDescriptionBinding; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.databinding.ItemMetadataBinding;
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List;
import org.schabi.newpipe.util.text.TextLinkifier;
import icepick.State; import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class DescriptionFragment extends BaseFragment { public class DescriptionFragment extends BaseDescriptionFragment {
@State @State
StreamInfo streamInfo = null; StreamInfo streamInfo = null;
final CompositeDisposable descriptionDisposables = new CompositeDisposable();
FragmentDescriptionBinding binding;
public DescriptionFragment() { public DescriptionFragment() {
} }
@ -49,86 +32,64 @@ public class DescriptionFragment extends BaseFragment {
this.streamInfo = streamInfo; this.streamInfo = streamInfo;
} }
@Nullable
@Override @Override
public View onCreateView(@NonNull final LayoutInflater inflater, protected Description getDescription() {
@Nullable final ViewGroup container, if (streamInfo == null) {
@Nullable final Bundle savedInstanceState) { return null;
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
if (streamInfo != null) {
setupUploadDate();
setupDescription();
setupMetadata(inflater, binding.detailMetadataLayout);
} }
return binding.getRoot(); return streamInfo.getDescription();
}
@Nullable
@Override
protected StreamingService getService() {
if (streamInfo == null) {
return null;
}
return streamInfo.getService();
} }
@Override @Override
public void onDestroy() { protected int getServiceId() {
descriptionDisposables.clear(); if (streamInfo == null) {
super.onDestroy(); return -1;
}
return streamInfo.getServiceId();
} }
@Nullable
@Override
protected String getStreamUrl() {
if (streamInfo == null) {
return null;
}
return streamInfo.getUrl();
}
private void setupUploadDate() { @Nullable
if (streamInfo.getUploadDate() != null) { @Override
public List<String> getTags() {
if (streamInfo == null) {
return null;
}
return streamInfo.getTags();
}
@Override
protected void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
if (streamInfo != null && streamInfo.getUploadDate() != null) {
binding.detailUploadDateView.setText(Localization binding.detailUploadDateView.setText(Localization
.localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime()));
} else { } else {
binding.detailUploadDateView.setVisibility(View.GONE); binding.detailUploadDateView.setVisibility(View.GONE);
} }
}
if (streamInfo == null) {
private void setupDescription() {
final Description description = streamInfo.getDescription();
if (description == null || isEmpty(description.getContent())
|| description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return; return;
} }
// start with disabled state. This also loads description content (!)
disableDescriptionSelection();
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
disableDescriptionSelection();
} else {
// enable selection only when button is clicked to prevent flickering
enableDescriptionSelection();
}
});
}
private void enableDescriptionSelection() {
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
binding.detailDescriptionView.setTextIsSelectable(true);
final String buttonLabel = getString(R.string.description_select_disable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
}
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
TextLinkifier.fromDescription(binding.detailDescriptionView,
streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY,
streamInfo.getService(), streamInfo.getUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
final String buttonLabel = getString(R.string.description_select_enable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
private void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
addMetadataItem(inflater, layout, false, R.string.metadata_category, addMetadataItem(inflater, layout, false, R.string.metadata_category,
streamInfo.getCategory()); streamInfo.getCategory());
@ -153,67 +114,6 @@ public class DescriptionFragment extends BaseFragment {
streamInfo.getHost()); streamInfo.getHost());
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url, addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
streamInfo.getThumbnailUrl()); streamInfo.getThumbnailUrl());
addTagsMetadataItem(inflater, layout);
}
private void addMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
final boolean linkifyContent,
@StringRes final int type,
@Nullable final String content) {
if (isBlank(content)) {
return;
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
ShareUtils.copyToClipboard(requireContext(), content);
return true;
});
if (linkifyContent) {
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
if (streamInfo.getTags() != null && !streamInfo.getTags().isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
streamInfo.getTags().stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false);
chip.setText(tag);
chip.setOnClickListener(this::onTagClick);
chip.setOnLongClickListener(this::onTagLongClick);
itemBinding.metadataTagsChips.addView(chip);
});
layout.addView(itemBinding.getRoot());
}
}
private void onTagClick(final View chip) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
streamInfo.getServiceId(), ((Chip) chip).getText().toString());
}
}
private boolean onTagLongClick(final View chip) {
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
return true;
} }
private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {

View file

@ -112,6 +112,7 @@ import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
@ -535,9 +536,11 @@ public final class VideoDetailFragment
})); }));
binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info ->
openBackgroundPlayer(true))); openBackgroundPlayer(true)
));
binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info ->
openPopupPlayer(true))); openPopupPlayer(true)
));
binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info ->
NavigationHelper.openDownloads(activity))); NavigationHelper.openDownloads(activity)));
@ -620,8 +623,7 @@ public final class VideoDetailFragment
final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> {
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN if (motionEvent.getAction() == MotionEvent.ACTION_DOWN
&& PreferenceManager.getDefaultSharedPreferences(activity) && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) {
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () ->
animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000));

View file

@ -16,7 +16,6 @@ import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.views.NewPipeRecyclerView; import org.schabi.newpipe.views.NewPipeRecyclerView;
@ -234,11 +233,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
showListFooter(hasMoreItems()); showListFooter(hasMoreItems());
} else { } else {
infoListAdapter.clearStreamItemList(); infoListAdapter.clearStreamItemList();
// showEmptyState should be called only if there is no item as showEmptyState();
// well as no header in infoListAdapter
if (!(result instanceof ChannelInfo && infoListAdapter.getItemCount() == 1)) {
showEmptyState();
}
} }
} }

View file

@ -0,0 +1,107 @@
package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import java.util.List;
import icepick.State;
public class ChannelAboutFragment extends BaseDescriptionFragment {
@State
protected ChannelInfo channelInfo;
public static ChannelAboutFragment getInstance(final ChannelInfo channelInfo) {
final ChannelAboutFragment fragment = new ChannelAboutFragment();
fragment.channelInfo = channelInfo;
return fragment;
}
public ChannelAboutFragment() {
super();
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0);
}
@Nullable
@Override
protected Description getDescription() {
if (channelInfo == null) {
return null;
}
return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT);
}
@Nullable
@Override
protected StreamingService getService() {
if (channelInfo == null) {
return null;
}
return channelInfo.getService();
}
@Override
protected int getServiceId() {
if (channelInfo == null) {
return -1;
}
return channelInfo.getServiceId();
}
@Nullable
@Override
protected String getStreamUrl() {
return null;
}
@Nullable
@Override
public List<String> getTags() {
if (channelInfo == null) {
return null;
}
return channelInfo.getTags();
}
@Override
protected void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
// There is no upload date available for channels, so hide the relevant UI element
binding.detailUploadDateView.setVisibility(View.GONE);
if (channelInfo == null) {
return;
}
final Context context = getContext();
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
Localization.localizeNumber(context, channelInfo.getSubscriberCount()));
}
addMetadataItem(inflater, layout, true, R.string.metadata_avatar_url,
channelInfo.getAvatarUrl());
addMetadataItem(inflater, layout, true, R.string.metadata_banner_url,
channelInfo.getBannerUrl());
}
}

View file

@ -1,10 +1,12 @@
package org.schabi.newpipe.fragments.list.channel; package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor; import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Color; import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
@ -16,51 +18,49 @@ import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.graphics.ColorUtils;
import androidx.preference.PreferenceManager;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
import com.jakewharton.rxbinding4.view.RxView; import com.jakewharton.rxbinding4.view.RxView;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.NotificationMode; import org.schabi.newpipe.database.subscription.NotificationMode;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
import org.schabi.newpipe.databinding.FragmentChannelBinding; import org.schabi.newpipe.databinding.FragmentChannelBinding;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.detail.TabAdapter;
import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper; import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.List; import java.util.List;
import java.util.Queue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Action; import io.reactivex.rxjava3.functions.Action;
@ -68,29 +68,37 @@ import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo> public class ChannelFragment extends BaseStateFragment<ChannelInfo>
implements View.OnClickListener { implements StateSaver.WriteRead {
private static final int BUTTON_DEBOUNCE_INTERVAL = 100; private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG";
@State
protected int serviceId = Constants.NO_SERVICE_ID;
@State
protected String name;
@State
protected String url;
private ChannelInfo currentInfo;
private Disposable currentWorker;
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
private Disposable subscribeButtonMonitor; private Disposable subscribeButtonMonitor;
private SubscriptionManager subscriptionManager;
private int lastTab;
private boolean channelContentNotSupported = false; private boolean channelContentNotSupported = false;
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Views // Views
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
private SubscriptionManager subscriptionManager; private FragmentChannelBinding binding;
private TabAdapter tabAdapter;
private FragmentChannelBinding channelBinding;
private ChannelHeaderBinding headerBinding;
private PlaylistControlBinding playlistControlBinding;
private MenuItem menuRssButton; private MenuItem menuRssButton;
private MenuItem menuNotifyButton; private MenuItem menuNotifyButton;
private SubscriptionEntity channelSubscription;
public static ChannelFragment getInstance(final int serviceId, final String url, public static ChannelFragment getInstance(final int serviceId, final String url,
final String name) { final String name) {
@ -99,22 +107,23 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
return instance; return instance;
} }
public ChannelFragment() { private void setInitialData(final int sid, final String u, final String title) {
super(UserAction.REQUESTED_CHANNEL); this.serviceId = sid;
this.url = u;
this.name = !TextUtils.isEmpty(title) ? title : "";
} }
@Override
public void onResume() {
super.onResume();
if (activity != null && useAsFrontPage) {
setTitle(currentInfo != null ? currentInfo.getName() : name);
}
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// LifeCycle // LifeCycle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
}
@Override @Override
public void onAttach(@NonNull final Context context) { public void onAttach(@NonNull final Context context) {
super.onAttach(context); super.onAttach(context);
@ -125,49 +134,58 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
public View onCreateView(@NonNull final LayoutInflater inflater, public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container, @Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) { @Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_channel, container, false); binding = FragmentChannelBinding.inflate(inflater, container, false);
return binding.getRoot();
} }
@Override @Override // called from onViewCreated in BaseFragment.onViewCreated
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState); super.initViews(rootView, savedInstanceState);
channelBinding = FragmentChannelBinding.bind(rootView);
showContentNotSupportedIfNeeded();
}
@Override tabAdapter = new TabAdapter(getChildFragmentManager());
public void onDestroy() { binding.viewPager.setAdapter(tabAdapter);
super.onDestroy(); binding.tabLayout.setupWithViewPager(binding.viewPager);
disposables.clear();
if (subscribeButtonMonitor != null) { setTitle(name);
subscribeButtonMonitor.dispose(); binding.channelTitleView.setText(name);
if (!PicassoHelper.getShouldLoadImages()) {
// do not waste space for the banner if it is not going to be loaded
binding.channelBannerImage.setImageDrawable(null);
} }
channelBinding = null;
headerBinding = null;
playlistControlBinding = null;
}
/*//////////////////////////////////////////////////////////////////////////
// Init
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Supplier<View> getListHeaderSupplier() {
headerBinding = ChannelHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
playlistControlBinding = headerBinding.playlistControl;
return headerBinding::getRoot;
} }
@Override @Override
protected void initListeners() { protected void initListeners() {
super.initListeners(); super.initListeners();
headerBinding.subChannelTitleView.setOnClickListener(this); final View.OnClickListener openSubChannel = v -> {
headerBinding.subChannelAvatarView.setOnClickListener(this); if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) {
try {
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
currentInfo.getParentChannelUrl(),
currentInfo.getParentChannelName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
}
} else if (DEBUG) {
Log.i(TAG, "Can't open parent channel because we got no channel URL");
}
};
binding.subChannelAvatarView.setOnClickListener(openSubChannel);
binding.subChannelTitleView.setOnClickListener(openSubChannel);
} }
@Override
public void onDestroy() {
super.onDestroy();
if (currentWorker != null) {
currentWorker.dispose();
}
disposables.clear();
binding = null;
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Menu // Menu
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -176,32 +194,33 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
public void onCreateOptionsMenu(@NonNull final Menu menu, public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) { @NonNull final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater); super.onCreateOptionsMenu(menu, inflater);
final ActionBar supportActionBar = activity.getSupportActionBar(); inflater.inflate(R.menu.menu_channel, menu);
if (useAsFrontPage && supportActionBar != null) {
supportActionBar.setDisplayHomeAsUpEnabled(false);
} else {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: " Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "menu = [" + menu + "], inflater = [" + inflater + "]");
}
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
} }
} }
@Override @Override
public boolean onOptionsItemSelected(final MenuItem item) { public void onPrepareOptionsMenu(@NonNull final Menu menu) {
super.onPrepareOptionsMenu(menu);
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
updateNotifyButton(channelSubscription);
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_notify: case R.id.menu_item_notify:
final boolean value = !item.isChecked(); final boolean value = !item.isChecked();
item.setEnabled(false); item.setEnabled(false);
setNotify(value); setNotify(value);
break; break;
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_rss: case R.id.menu_item_rss:
if (currentInfo != null) { if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
@ -224,13 +243,14 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
return true; return true;
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Channel Subscription // Channel Subscription
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
private void monitorSubscription(final ChannelInfo info) { private void monitorSubscription(final ChannelInfo info) {
final Consumer<Throwable> onError = (Throwable throwable) -> { final Consumer<Throwable> onError = (Throwable throwable) -> {
animate(headerBinding.channelSubscribeButton, false, 100); animate(binding.channelSubscribeButton, false, 100);
showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET,
"Get subscription status", currentInfo)); "Get subscription status", currentInfo));
}; };
@ -263,10 +283,9 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
}, onError)); }, onError));
} }
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
final ChannelInfo info) {
return (@NonNull Object o) -> { return (@NonNull Object o) -> {
subscriptionManager.insertSubscription(subscription, info); subscriptionManager.insertSubscription(subscription);
return o; return o;
}; };
} }
@ -298,8 +317,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
.subscribe(onComplete, onError)); .subscribe(onComplete, onError));
} }
private Disposable monitorSubscribeButton(final Button subscribeButton, private Disposable monitorSubscribeButton(final Function<Object, Object> action) {
final Function<Object, Object> action) {
final Consumer<Object> onNext = (@NonNull Object o) -> { final Consumer<Object> onNext = (@NonNull Object o) -> {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "Changed subscription status to this channel!"); Log.d(TAG, "Changed subscription status to this channel!");
@ -311,7 +329,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
"Changing subscription for " + currentInfo.getUrl(), currentInfo)); "Changing subscription for " + currentInfo.getUrl(), currentInfo));
/* Emit clicks from main thread unto io thread */ /* Emit clicks from main thread unto io thread */
return RxView.clicks(subscribeButton) return RxView.clicks(binding.channelSubscribeButton)
.subscribeOn(AndroidSchedulers.mainThread()) .subscribeOn(AndroidSchedulers.mainThread())
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks
@ -340,17 +358,17 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
info.getAvatarUrl(), info.getAvatarUrl(),
info.getDescription(), info.getDescription(),
info.getSubscriberCount()); info.getSubscriberCount());
channelSubscription = null;
updateNotifyButton(null); updateNotifyButton(null);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel));
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
} else { } else {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "Found subscription to this channel!"); Log.d(TAG, "Found subscription to this channel!");
} }
final SubscriptionEntity subscription = subscriptionEntities.get(0); channelSubscription = subscriptionEntities.get(0);
updateNotifyButton(subscription); updateNotifyButton(channelSubscription);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor =
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); monitorSubscribeButton(mapOnUnsubscribe(channelSubscription));
} }
}; };
} }
@ -361,34 +379,33 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
+ "isSubscribed = [" + isSubscribed + "]"); + "isSubscribed = [" + isSubscribed + "]");
} }
final boolean isButtonVisible = headerBinding.channelSubscribeButton.getVisibility() final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility()
== View.VISIBLE; == View.VISIBLE;
final int backgroundDuration = isButtonVisible ? 300 : 0; final int backgroundDuration = isButtonVisible ? 300 : 0;
final int textDuration = isButtonVisible ? 200 : 0; final int textDuration = isButtonVisible ? 200 : 0;
final int subscribeBackground = ThemeHelper
.resolveColorFromAttr(activity, R.attr.colorPrimary);
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
final int subscribedBackground = ContextCompat final int subscribedBackground = ContextCompat
.getColor(activity, R.color.subscribed_background_color); .getColor(activity, R.color.subscribed_background_color);
final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color); final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color);
final int subscribeBackground = ColorUtils.blendARGB(ThemeHelper
.resolveColorFromAttr(activity, R.attr.colorPrimary), subscribedBackground, 0.35f);
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
if (!isSubscribed) { if (isSubscribed) {
headerBinding.channelSubscribeButton.setText(R.string.subscribe_button_title); binding.channelSubscribeButton.setText(R.string.subscribed_button_title);
animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration, animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration,
subscribedBackground, subscribeBackground);
animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribedText,
subscribeText);
} else {
headerBinding.channelSubscribeButton.setText(R.string.subscribed_button_title);
animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration,
subscribeBackground, subscribedBackground); subscribeBackground, subscribedBackground);
animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribeText, animateTextColor(binding.channelSubscribeButton, textDuration, subscribeText,
subscribedText); subscribedText);
} else {
binding.channelSubscribeButton.setText(R.string.subscribe_button_title);
animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration,
subscribedBackground, subscribeBackground);
animateTextColor(binding.channelSubscribeButton, textDuration, subscribedText,
subscribeText);
} }
animate(headerBinding.channelSubscribeButton, true, 100, animate(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA);
AnimationType.LIGHT_SCALE_AND_ALPHA);
} }
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
@ -424,108 +441,179 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
* Show a snackbar with the option to enable notifications on new streams for this channel. * Show a snackbar with the option to enable notifications on new streams for this channel.
*/ */
private void showNotifySnackbar() { private void showNotifySnackbar() {
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
.setAction(R.string.get_notified, v -> setNotify(true)) .setAction(R.string.get_notified, v -> setNotify(true))
.setActionTextColor(Color.YELLOW) .setActionTextColor(Color.YELLOW)
.show(); .show();
} }
/*//////////////////////////////////////////////////////////////////////////
// Load and handle
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
}
@Override
protected Single<ChannelInfo> loadResult(final boolean forceLoad) {
return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad);
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// OnClick // Init
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override private void updateTabs() {
public void onClick(final View v) { tabAdapter.clearAllItems();
if (isLoading.get() || currentInfo == null) {
return;
}
switch (v.getId()) { if (currentInfo != null && !channelContentNotSupported) {
case R.id.sub_channel_avatar_view: final Context context = requireContext();
case R.id.sub_channel_title_view: final SharedPreferences preferences = PreferenceManager
if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { .getDefaultSharedPreferences(context);
try {
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), for (final ListLinkHandler linkHandler : currentInfo.getTabs()) {
currentInfo.getParentChannelUrl(), final String tab = linkHandler.getContentFilters().get(0);
currentInfo.getParentChannelName()); if (ChannelTabHelper.showChannelTab(context, preferences, tab)) {
} catch (final Exception e) { final ChannelTabFragment channelTabFragment =
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); ChannelTabFragment.getInstance(serviceId, linkHandler, name);
} channelTabFragment.useAsFrontPage(useAsFrontPage);
} else if (DEBUG) { tabAdapter.addFragment(channelTabFragment,
Log.i(TAG, "Can't open parent channel because we got no channel URL"); context.getString(ChannelTabHelper.getTranslationKey(tab)));
} }
break; }
if (ChannelTabHelper.showChannelTab(
context, preferences, R.string.show_channel_tabs_about)) {
tabAdapter.addFragment(
ChannelAboutFragment.getInstance(currentInfo),
context.getString(R.string.channel_tab_about));
}
}
tabAdapter.notifyDataSetUpdate();
for (int i = 0; i < tabAdapter.getCount(); i++) {
binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i));
}
// Restore previously selected tab
final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab);
if (ltab != null) {
binding.tabLayout.selectTab(ltab);
} }
} }
/*//////////////////////////////////////////////////////////////////////////
// State Saving
//////////////////////////////////////////////////////////////////////////*/
@Override
public String generateSuffix() {
return null;
}
@Override
public void writeTo(final Queue<Object> objectsToSave) {
objectsToSave.add(currentInfo);
objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition());
}
@Override
public void readFrom(@NonNull final Queue<Object> savedObjects) {
currentInfo = (ChannelInfo) savedObjects.poll();
lastTab = (Integer) savedObjects.poll();
}
@Override
public void onSaveInstanceState(final @NonNull Bundle outState) {
super.onSaveInstanceState(outState);
if (binding != null) {
outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition());
}
}
@Override
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
lastTab = savedInstanceState.getInt("LastTab", 0);
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Contract // Contract
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override
protected void doInitialLoadLogic() {
if (currentInfo == null) {
startLoading(false);
} else {
handleResult(currentInfo);
}
}
@Override
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
currentInfo = null;
updateTabs();
if (currentWorker != null) {
currentWorker.dispose();
}
runWorker(forceLoad);
}
private void runWorker(final boolean forceLoad) {
currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
isLoading.set(false);
handleResult(result);
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
url == null ? "No URL" : url, serviceId)));
}
@Override @Override
public void showLoading() { public void showLoading() {
super.showLoading(); super.showLoading();
PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG);
animate(headerBinding.channelSubscribeButton, false, 100); animate(binding.channelSubscribeButton, false, 100);
} }
@Override @Override
public void handleResult(@NonNull final ChannelInfo result) { public void handleResult(@NonNull final ChannelInfo result) {
super.handleResult(result); super.handleResult(result);
currentInfo = result;
setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName());
if (PicassoHelper.getShouldLoadImages() && !isBlank(result.getBannerUrl())) {
PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG)
.into(binding.channelBannerImage);
} else {
// do not waste space for the banner, if the user disabled images or there is not one
binding.channelBannerImage.setImageDrawable(null);
}
headerBinding.getRoot().setVisibility(View.VISIBLE);
PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG)
.into(headerBinding.channelBannerImage);
PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG) PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG)
.into(headerBinding.channelAvatarView); .into(binding.channelAvatarView);
PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG) PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG)
.into(headerBinding.subChannelAvatarView); .into(binding.subChannelAvatarView);
headerBinding.channelSubscriberView.setVisibility(View.VISIBLE); binding.channelTitleView.setText(result.getName());
binding.channelSubscriberView.setVisibility(View.VISIBLE);
if (result.getSubscriberCount() >= 0) { if (result.getSubscriberCount() >= 0) {
headerBinding.channelSubscriberView.setText(Localization binding.channelSubscriberView.setText(Localization
.shortSubscriberCount(activity, result.getSubscriberCount())); .shortSubscriberCount(activity, result.getSubscriberCount()));
} else { } else {
headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available); binding.channelSubscriberView.setText(R.string.subscribers_count_not_available);
} }
if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) {
headerBinding.subChannelTitleView.setText(String.format( binding.subChannelTitleView.setText(String.format(
getString(R.string.channel_created_by), getString(R.string.channel_created_by),
currentInfo.getParentChannelName()) currentInfo.getParentChannelName())
); );
headerBinding.subChannelTitleView.setVisibility(View.VISIBLE); binding.subChannelTitleView.setVisibility(View.VISIBLE);
headerBinding.subChannelAvatarView.setVisibility(View.VISIBLE); binding.subChannelAvatarView.setVisibility(View.VISIBLE);
} else {
headerBinding.subChannelTitleView.setVisibility(View.GONE);
} }
if (menuRssButton != null) { if (menuRssButton != null) {
menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl()));
} }
// PlaylistControls should be visible only if there is some item in
// infoListAdapter other than header
if (infoListAdapter.getItemCount() != 1) {
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
} else {
playlistControlBinding.getRoot().setVisibility(View.GONE);
}
channelContentNotSupported = false; channelContentNotSupported = false;
for (final Throwable throwable : result.getErrors()) { for (final Throwable throwable : result.getErrors()) {
if (throwable instanceof ContentNotSupportedException) { if (throwable instanceof ContentNotSupportedException) {
@ -539,62 +627,21 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
if (subscribeButtonMonitor != null) { if (subscribeButtonMonitor != null) {
subscribeButtonMonitor.dispose(); subscribeButtonMonitor.dispose();
} }
updateTabs();
updateSubscription(result); updateSubscription(result);
monitorSubscription(result); monitorSubscription(result);
playlistControlBinding.playlistCtrlPlayAllButton
.setOnClickListener(view -> NavigationHelper
.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton
.setOnClickListener(view -> NavigationHelper
.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton
.setOnClickListener(view -> NavigationHelper
.playOnBackgroundPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
} }
private void showContentNotSupportedIfNeeded() { private void showContentNotSupportedIfNeeded() {
// channelBinding might not be initialized when handleResult() is called // channelBinding might not be initialized when handleResult() is called
// (e.g. after rotating the screen, #6696) // (e.g. after rotating the screen, #6696)
if (!channelContentNotSupported || channelBinding == null) { if (!channelContentNotSupported || binding == null) {
return; return;
} }
channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE); binding.errorContentNotSupported.setVisibility(View.VISIBLE);
channelBinding.channelKaomoji.setText("(︶︹︺)"); binding.channelKaomoji.setText("(︶︹︺)");
channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
channelBinding.emptyStateMessage.setVisibility(View.GONE);
}
private PlayQueue getPlayQueue() {
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList());
return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(),
currentInfo.getNextPage(), streamItems, 0);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) {
super.setTitle(title);
if (!useAsFrontPage) {
headerBinding.channelTitleView.setText(title);
}
} }
} }

View file

@ -0,0 +1,140 @@
package org.schabi.newpipe.fragments.list.channel;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.core.Single;
public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTabInfo>
implements PlaylistControlViewHolder {
// states must be protected and not private for IcePick being able to access them
@State
protected ListLinkHandler tabHandler;
@State
protected String channelName;
private PlaylistControlBinding playlistControlBinding;
@NonNull
public static ChannelTabFragment getInstance(final int serviceId,
final ListLinkHandler tabHandler,
final String channelName) {
final ChannelTabFragment instance = new ChannelTabFragment();
instance.serviceId = serviceId;
instance.tabHandler = tabHandler;
instance.channelName = channelName;
return instance;
}
public ChannelTabFragment() {
super(UserAction.REQUESTED_CHANNEL);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(false);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
}
@Override
public void onDestroyView() {
super.onDestroyView();
playlistControlBinding = null;
}
@Override
protected Supplier<View> getListHeaderSupplier() {
if (ChannelTabHelper.isStreamsTab(tabHandler)) {
playlistControlBinding = PlaylistControlBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
return playlistControlBinding::getRoot;
}
return null;
}
@Override
protected Single<ChannelTabInfo> loadResult(final boolean forceLoad) {
return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad);
}
@Override
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage);
}
@Override
public void setTitle(final String title) {
// The channel name is displayed as title in the toolbar.
// The title is always a description of the content of the tab fragment.
// It should be unique for each channel because multiple channel tabs
// can be added to the main page. Therefore, the channel name is used.
// Using the title variable would cause the title to be the same for all channel tabs.
super.setTitle(channelName);
}
@Override
public void handleResult(@NonNull final ChannelTabInfo result) {
super.handleResult(result);
if (playlistControlBinding != null) {
// PlaylistControls should be visible only if there is some item in
// infoListAdapter other than header
if (infoListAdapter.getItemCount() > 1) {
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
} else {
playlistControlBinding.getRoot().setVisibility(View.GONE);
}
PlayButtonHelper.initPlaylistControlClickListener(
activity, playlistControlBinding, this);
}
}
public PlayQueue getPlayQueue() {
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList());
return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler,
currentInfo.getNextPage(), streamItems, 0);
}
}

View file

@ -0,0 +1,11 @@
package org.schabi.newpipe.fragments.list.playlist;
import org.schabi.newpipe.player.playqueue.PlayQueue;
/**
* Interface for {@code R.layout.playlist_control} view holders
* to give access to the play queue.
*/
public interface PlaylistControlViewHolder {
PlayQueue getPlayQueue();
}

View file

@ -43,7 +43,6 @@ import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
@ -51,6 +50,7 @@ import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -64,7 +64,8 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> { public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo>
implements PlaylistControlViewHolder {
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
@ -332,25 +333,10 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistBookmarkSubscriber()); .subscribe(getPlaylistBookmarkSubscriber());
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
} }
private PlayQueue getPlayQueue() { public PlayQueue getPlayQueue() {
return getPlayQueue(0); return getPlayQueue(0);
} }

View file

@ -60,6 +60,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.databinding.FragmentFeedBinding import org.schabi.newpipe.databinding.FragmentFeedBinding
import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
@ -453,24 +454,33 @@ class FeedFragment : BaseStateFragment<FeedState>() {
if (t is FeedLoadService.RequestException && if (t is FeedLoadService.RequestException &&
t.cause is ContentNotAvailableException t.cause is ContentNotAvailableException
) { ) {
Single.fromCallable { disposables.add(
NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() Single.fromCallable {
.getSubscription(t.subscriptionId) NewPipeDatabase.getInstance(requireContext()).subscriptionDAO()
}.subscribeOn(Schedulers.io()) .getSubscription(t.subscriptionId)
.observeOn(AndroidSchedulers.mainThread()) }
.subscribe( .subscribeOn(Schedulers.io())
{ subscriptionEntity -> .observeOn(AndroidSchedulers.mainThread())
handleFeedNotAvailable( .subscribe(
subscriptionEntity, { subscriptionEntity ->
t.cause, handleFeedNotAvailable(
errors.subList(i + 1, errors.size) subscriptionEntity,
) t.cause,
}, errors.subList(i + 1, errors.size)
{ throwable -> Log.e(TAG, "Unable to process", throwable) } )
) },
return // this will be called on the remaining errors by handleFeedNotAvailable() { throwable -> Log.e(TAG, "Unable to process", throwable) }
)
)
// this will be called on the remaining errors by handleFeedNotAvailable()
return@handleItemsErrors
} }
} }
if (errors.isNotEmpty()) {
// if no error was a ContentNotAvailableException, show a general error snackbar
ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, ""))
}
} }
private fun handleFeedNotAvailable( private fun handleFeedNotAvailable(

View file

@ -13,9 +13,9 @@ sealed class FeedState {
data class LoadedState( data class LoadedState(
val items: List<StreamItem>, val items: List<StreamItem>,
val oldestUpdate: OffsetDateTime? = null, val oldestUpdate: OffsetDateTime?,
val notLoadedCount: Long, val notLoadedCount: Long,
val itemsErrors: List<Throwable> = emptyList() val itemsErrors: List<Throwable>
) : FeedState() ) : FeedState()
data class ErrorState( data class ErrorState(

View file

@ -86,7 +86,7 @@ class FeedViewModel(
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue( mutableStateLiveData.postValue(
when (event) { when (event) {
is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount) is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf())
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors)
is ErrorResultEvent -> FeedState.ErrorState(event.error) is ErrorResultEvent -> FeedState.ErrorState(event.error)

View file

@ -58,7 +58,7 @@ class NotificationHelper(val context: Context) {
.setAutoCancel(true) .setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_SOCIAL) .setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setGroupSummary(true) .setGroupSummary(true)
.setGroup(data.listInfo.url) .setGroup(data.originalInfo.url)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
// Build a summary notification for Android versions < 7.0 // Build a summary notification for Android versions < 7.0
@ -73,7 +73,7 @@ class NotificationHelper(val context: Context) {
context, context,
data.pseudoId, data.pseudoId,
NavigationHelper NavigationHelper
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url) .getChannelIntent(context, data.originalInfo.serviceId, data.originalInfo.url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
0, 0,
false false
@ -88,7 +88,7 @@ class NotificationHelper(val context: Context) {
// Show individual stream notifications, set channel icon only if there is actually // Show individual stream notifications, set channel icon only if there is actually
// one // one
showStreamNotifications(newStreams, data.listInfo.serviceId, bitmap) showStreamNotifications(newStreams, data.originalInfo.serviceId, bitmap)
// Show summary notification // Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build()) manager.notify(data.pseudoId, summaryBuilder.build())
@ -97,7 +97,7 @@ class NotificationHelper(val context: Context) {
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
// Show individual stream notifications // Show individual stream notifications
showStreamNotifications(newStreams, data.listInfo.serviceId, null) showStreamNotifications(newStreams, data.originalInfo.serviceId, null)
// Show summary notification // Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build()) manager.notify(data.pseudoId, summaryBuilder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected iconLoadingTargets.remove(this) // allow it to be garbage-collected

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.local.feed.service package org.schabi.newpipe.local.feed.service
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
@ -13,11 +14,17 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.ChannelTabHelper
import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo
import org.schabi.newpipe.util.ExtractorHelper.getChannelTab
import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -75,7 +82,9 @@ class FeedLoadManager(private val context: Context) {
* subscriptions which have not been updated within the feed updated threshold * subscriptions which have not been updated within the feed updated threshold
*/ */
val outdatedSubscriptions = when (groupId) { val outdatedSubscriptions = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(
outdatedThreshold
)
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode( GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
outdatedThreshold, NotificationMode.ENABLED outdatedThreshold, NotificationMode.ENABLED
) )
@ -101,52 +110,7 @@ class FeedLoadManager(private val context: Context) {
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() } .filter { !cancelSignal.get() }
.map { subscriptionEntity -> .map { subscriptionEntity ->
var error: Throwable? = null loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences)
try {
// check for and load new streams
// either by using the dedicated feed method or by getting the channel info
val listInfo = if (useFeedExtractor) {
ExtractorHelper
.getFeedInfoFallbackToChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url
)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} else {
ExtractorHelper
.getChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url,
true
)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} as ListInfo<StreamInfoItem>
return@map Notification.createOnNext(
FeedUpdateInfo(
subscriptionEntity,
listInfo
)
)
} catch (e: Throwable) {
if (error == null) {
// do this to prevent blockingGet() from wrapping into RuntimeException
error = e
}
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper =
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
}
} }
.sequential() .sequential()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -164,7 +128,112 @@ class FeedLoadManager(private val context: Context) {
} }
private fun broadcastProgress() { private fun broadcastProgress() {
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get())) FeedEventManager.postEvent(
FeedEventManager.Event.ProgressEvent(
currentProgress.get(),
maxProgress.get()
)
)
}
private fun loadStreams(
subscriptionEntity: SubscriptionEntity,
useFeedExtractor: Boolean,
defaultSharedPreferences: SharedPreferences
): Notification<FeedUpdateInfo> {
var error: Throwable? = null
val storeOriginalErrorAndRethrow = { e: Throwable ->
// keep original to prevent blockingGet() from wrapping it into RuntimeException
error = e
throw e
}
try {
// check for and load new streams
// either by using the dedicated feed method or by getting the channel info
var originalInfo: Info? = null
var streams: List<StreamInfoItem>? = null
val errors = ArrayList<Throwable>()
if (useFeedExtractor) {
NewPipe.getService(subscriptionEntity.serviceId)
.getFeedExtractor(subscriptionEntity.url)
?.also { feedExtractor ->
// the user wants to use a feed extractor and there is one, use it
val feedInfo = FeedInfo.getInfo(feedExtractor)
errors.addAll(feedInfo.errors)
originalInfo = feedInfo
streams = feedInfo.relatedItems
}
}
if (originalInfo == null) {
// use the normal channel tabs extractor if either the user wants it, or
// the current service does not have a dedicated feed extractor
val channelInfo = getChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url, true
)
.onErrorReturn(storeOriginalErrorAndRethrow)
.blockingGet()
errors.addAll(channelInfo.errors)
originalInfo = channelInfo
streams = channelInfo.tabs
.filter { tab ->
ChannelTabHelper.fetchFeedChannelTab(
context,
defaultSharedPreferences,
tab
)
}
.map {
Pair(
getChannelTab(subscriptionEntity.serviceId, it, true)
.onErrorReturn(storeOriginalErrorAndRethrow)
.blockingGet(),
it
)
}
.flatMap { (channelTabInfo, linkHandler) ->
errors.addAll(channelTabInfo.errors)
if (channelTabInfo.relatedItems.isEmpty() &&
channelTabInfo.nextPage != null
) {
val infoItemsPage = getMoreChannelTabItems(
subscriptionEntity.serviceId,
linkHandler, channelTabInfo.nextPage
)
.blockingGet()
errors.addAll(infoItemsPage.errors)
return@flatMap infoItemsPage.items
} else {
return@flatMap channelTabInfo.relatedItems
}
}
.filterIsInstance<StreamInfoItem>()
}
return Notification.createOnNext(
FeedUpdateInfo(
subscriptionEntity,
originalInfo!!,
streams!!,
errors,
)
)
} catch (e: Throwable) {
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper = FeedLoadService.RequestException(
subscriptionEntity.uid,
request,
// do this to prevent blockingGet() from wrapping into RuntimeException
error ?: e
)
return Notification.createOnError(wrapper)
}
} }
/** /**
@ -203,24 +272,24 @@ class FeedLoadManager(private val context: Context) {
for (notification in list) { for (notification in list) {
when { when {
notification.isOnNext -> { notification.isOnNext -> {
val subscriptionId = notification.value!!.uid val info = notification.value!!
val info = notification.value!!.listInfo
notification.value!!.newStreams = filterNewStreams( notification.value!!.newStreams = filterNewStreams(info.streams)
notification.value!!.listInfo.relatedItems
)
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) feedDatabaseManager.upsertAll(info.uid, info.streams)
subscriptionManager.updateFromInfo(subscriptionId, info) subscriptionManager.updateFromInfo(info.uid, info.originalInfo)
if (info.errors.isNotEmpty()) { if (info.errors.isNotEmpty()) {
feedResultsHolder.addErrors( feedResultsHolder.addErrors(
FeedLoadService.RequestException.wrapList( info.errors.map {
subscriptionId, FeedLoadService.RequestException(
info info.uid,
) "${info.originalInfo.serviceId}:${info.originalInfo.url}",
it
)
}
) )
feedDatabaseManager.markAsOutdated(subscriptionId) feedDatabaseManager.markAsOutdated(info.uid)
} }
} }
notification.isOnError -> { notification.isOnError -> {

View file

@ -39,8 +39,6 @@ import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -126,17 +124,7 @@ class FeedLoadService : Service() {
// Loading & Handling // Loading & Handling
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause)
companion object {
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
val toReturn = ArrayList<Throwable>(info.errors.size)
info.errors.mapTo(toReturn) {
RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, it)
}
return toReturn
}
}
}
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
// Notification // Notification

View file

@ -2,7 +2,7 @@ package org.schabi.newpipe.local.feed.service
import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class FeedUpdateInfo( data class FeedUpdateInfo(
@ -11,24 +11,30 @@ data class FeedUpdateInfo(
val notificationMode: Int, val notificationMode: Int,
val name: String, val name: String,
val avatarUrl: String, val avatarUrl: String,
val listInfo: ListInfo<StreamInfoItem>, val originalInfo: Info,
val streams: List<StreamInfoItem>,
val errors: List<Throwable>,
) { ) {
constructor( constructor(
subscription: SubscriptionEntity, subscription: SubscriptionEntity,
listInfo: ListInfo<StreamInfoItem>, originalInfo: Info,
streams: List<StreamInfoItem>,
errors: List<Throwable>,
) : this( ) : this(
uid = subscription.uid, uid = subscription.uid,
notificationMode = subscription.notificationMode, notificationMode = subscription.notificationMode,
name = subscription.name, name = subscription.name,
avatarUrl = subscription.avatarUrl, avatarUrl = subscription.avatarUrl,
listInfo = listInfo, originalInfo = originalInfo,
streams = streams,
errors = errors,
) )
/** /**
* Integer id, can be used as notification id, etc. * Integer id, can be used as notification id, etc.
*/ */
val pseudoId: Int val pseudoId: Int
get() = listInfo.url.hashCode() get() = originalInfo.url.hashCode()
lateinit var newStreams: List<StreamInfoItem> lateinit var newStreams: List<StreamInfoItem>
} }

View file

@ -28,14 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment; import org.schabi.newpipe.settings.HistorySettingsFragment;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -49,7 +51,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
public class StatisticsPlaylistFragment public class StatisticsPlaylistFragment
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void> { extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void>
implements PlaylistControlViewHolder {
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
@State @State
Parcelable itemsListState; Parcelable itemsListState;
@ -195,14 +198,9 @@ public class StatisticsPlaylistFragment
if (itemListAdapter != null) { if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener(); itemListAdapter.unsetSelectedListener();
} }
if (playlistControlBinding != null) {
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null);
headerBinding = null; headerBinding = null;
playlistControlBinding = null; playlistControlBinding = null;
}
if (databaseSubscription != null) { if (databaseSubscription != null) {
databaseSubscription.cancel(); databaseSubscription.cancel();
@ -276,12 +274,8 @@ public class StatisticsPlaylistFragment
itemsListState = null; itemsListState = null;
} }
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
headerBinding.sortButton.setOnClickListener(view -> toggleSortMode()); headerBinding.sortButton.setOnClickListener(view -> toggleSortMode());
hideLoading(); hideLoading();
@ -374,7 +368,7 @@ public class StatisticsPlaylistFragment
} }
} }
private PlayQueue getPlayQueue() { public PlayQueue getPlayQueue() {
return getPlayQueue(0); return getPlayQueue(0);
} }

View file

@ -22,7 +22,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.viewbinding.ViewBinding; import androidx.viewbinding.ViewBinding;
@ -42,17 +41,18 @@ import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -69,7 +69,8 @@ import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.PublishSubject; import io.reactivex.rxjava3.subjects.PublishSubject;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> { public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void>
implements PlaylistControlViewHolder {
// Save the list 10 seconds after the last change occurred // Save the list 10 seconds after the last change occurred
private static final long SAVE_DEBOUNCE_MILLIS = 10000; private static final long SAVE_DEBOUNCE_MILLIS = 10000;
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@ -265,14 +266,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
if (itemListAdapter != null) { if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener(); itemListAdapter.unsetSelectedListener();
} }
if (playlistControlBinding != null) {
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null);
headerBinding = null; headerBinding = null;
playlistControlBinding = null; playlistControlBinding = null;
}
if (databaseSubscription != null) { if (databaseSubscription != null) {
databaseSubscription.cancel(); databaseSubscription.cancel();
@ -498,38 +495,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} }
setVideoCount(itemListAdapter.getItemsList().size()); setVideoCount(itemListAdapter.getItemsList().size());
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> { PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
NavigationHelper.playOnMainPlayer(activity, getPlayQueue());
showHoldToAppendTipIfNeeded();
});
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> {
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false);
showHoldToAppendTipIfNeeded();
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> {
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false);
showHoldToAppendTipIfNeeded();
});
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
hideLoading(); hideLoading();
} }
private void showHoldToAppendTipIfNeeded() {
if (PreferenceManager.getDefaultSharedPreferences(activity)
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
Toast.makeText(activity, R.string.hold_to_append, Toast.LENGTH_SHORT).show();
}
}
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling // Fragment Error Handling
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
@ -853,7 +823,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} }
} }
private PlayQueue getPlayQueue() { public PlayQueue getPlayQueue() {
return getPlayQueue(0); return getPlayQueue(0);
} }

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.local.subscription package org.schabi.newpipe.local.subscription
import android.content.Context import android.content.Context
import android.util.Pair
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
@ -11,8 +12,9 @@ import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.FeedDatabaseManager
@ -46,28 +48,33 @@ class SubscriptionManager(context: Context) {
} }
} }
fun upsertAll(infoList: List<ChannelInfo>): List<SubscriptionEntity> { fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> {
val listEntities = subscriptionTable.upsertAll( val listEntities = subscriptionTable.upsertAll(
infoList.map { SubscriptionEntity.from(it) } infoList.map { SubscriptionEntity.from(it.first) }
) )
database.runInTransaction { database.runInTransaction {
infoList.forEachIndexed { index, info -> infoList.forEachIndexed { index, info ->
feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems) info.second.forEach {
feedDatabaseManager.upsertAll(
listEntities[index].uid,
it.relatedItems.filterIsInstance<StreamInfoItem>()
)
}
} }
} }
return listEntities return listEntities
} }
fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) fun updateChannelInfo(info: ChannelInfo): Completable =
.flatMapCompletable { subscriptionTable.getSubscription(info.serviceId, info.url)
Completable.fromRunnable { .flatMapCompletable {
it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) Completable.fromRunnable {
subscriptionTable.update(it) it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
feedDatabaseManager.upsertAll(it.uid, info.relatedItems) subscriptionTable.update(it)
}
} }
}
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
return subscriptionTable().getSubscription(serviceId, url) return subscriptionTable().getSubscription(serviceId, url)
@ -84,7 +91,7 @@ class SubscriptionManager(context: Context) {
} }
} }
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) { fun updateFromInfo(subscriptionId: Long, info: Info) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
if (info is FeedInfo) { if (info is FeedInfo) {
@ -107,11 +114,8 @@ class SubscriptionManager(context: Context) {
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
} }
fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) { fun insertSubscription(subscriptionEntity: SubscriptionEntity) {
database.runInTransaction { subscriptionTable.insert(subscriptionEntity)
val subscriptionId = subscriptionTable.insert(subscriptionEntity)
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
}
} }
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
@ -125,7 +129,10 @@ class SubscriptionManager(context: Context) {
*/ */
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable { private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false) return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } } .flatMap { info ->
ExtractorHelper.getChannelTab(subscription.serviceId, info.tabs.first(), false)
}
.map { channel -> channel.relatedItems.filterIsInstance<StreamInfoItem>().map { stream -> StreamEntity(stream) } }
.flatMapCompletable { entities -> .flatMapCompletable { entities ->
Completable.fromAction { Completable.fromAction {
database.streamDAO().upsertAll(entities) database.streamDAO().upsertAll(entities)

View file

@ -26,6 +26,7 @@ import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -38,6 +39,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.streams.io.SharpInputStream; import org.schabi.newpipe.streams.io.SharpInputStream;
@ -48,6 +50,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -199,12 +202,19 @@ public class SubscriptionsImportService extends BaseImportExportService {
.parallel(PARALLEL_EXTRACTIONS) .parallel(PARALLEL_EXTRACTIONS)
.runOn(Schedulers.io()) .runOn(Schedulers.io())
.map((Function<SubscriptionItem, Notification<ChannelInfo>>) subscriptionItem -> { .map((Function<SubscriptionItem, Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>>) subscriptionItem -> {
try { try {
return Notification.createOnNext(ExtractorHelper final ChannelInfo channelInfo = ExtractorHelper
.getChannelInfo(subscriptionItem.getServiceId(), .getChannelInfo(subscriptionItem.getServiceId(),
subscriptionItem.getUrl(), true) subscriptionItem.getUrl(), true)
.blockingGet()); .blockingGet();
return Notification.createOnNext(new Pair<>(channelInfo,
Collections.singletonList(
ExtractorHelper.getChannelTab(
subscriptionItem.getServiceId(),
channelInfo.getTabs().get(0), true).blockingGet()
)));
} catch (final Throwable e) { } catch (final Throwable e) {
return Notification.createOnError(e); return Notification.createOnError(e);
} }
@ -223,7 +233,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
} }
private Subscriber<List<SubscriptionEntity>> getSubscriber() { private Subscriber<List<SubscriptionEntity>> getSubscriber() {
return new Subscriber<List<SubscriptionEntity>>() { return new Subscriber<>() {
@Override @Override
public void onSubscribe(final Subscription s) { public void onSubscribe(final Subscription s) {
subscription = s; subscription = s;
@ -254,10 +264,11 @@ public class SubscriptionsImportService extends BaseImportExportService {
}; };
} }
private Consumer<Notification<ChannelInfo>> getNotificationsConsumer() { private Consumer<Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>> getNotificationsConsumer() {
return notification -> { return notification -> {
if (notification.isOnNext()) { if (notification.isOnNext()) {
final String name = notification.getValue().getName(); final String name = notification.getValue().first.getName();
eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : "");
} else if (notification.isOnError()) { } else if (notification.isOnError()) {
final Throwable error = notification.getError(); final Throwable error = notification.getError();
@ -275,10 +286,12 @@ public class SubscriptionsImportService extends BaseImportExportService {
}; };
} }
private Function<List<Notification<ChannelInfo>>, List<SubscriptionEntity>> upsertBatch() { private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>,
List<SubscriptionEntity>> upsertBatch() {
return notificationList -> { return notificationList -> {
final List<ChannelInfo> infoList = new ArrayList<>(notificationList.size()); final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList =
for (final Notification<ChannelInfo> n : notificationList) { new ArrayList<>(notificationList.size());
for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) {
if (n.isOnNext()) { if (n.isOnNext()) {
infoList.add(n.getValue()); infoList.add(n.getValue());
} }

View file

@ -4,6 +4,7 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
@ -15,7 +16,7 @@ import java.util.stream.Collectors;
import io.reactivex.rxjava3.core.SingleObserver; import io.reactivex.rxjava3.core.SingleObserver;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>> abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
extends PlayQueue { extends PlayQueue {
boolean isInitial; boolean isInitial;
private boolean isComplete; private boolean isComplete;
@ -27,7 +28,13 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
private transient Disposable fetchReactor; private transient Disposable fetchReactor;
protected AbstractInfoPlayQueue(final T info) { protected AbstractInfoPlayQueue(final T info) {
this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); this(info.getServiceId(), info.getUrl(), info.getNextPage(),
info.getRelatedItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList()),
0);
} }
protected AbstractInfoPlayQueue(final int serviceId, protected AbstractInfoPlayQueue(final int serviceId,
@ -72,7 +79,11 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
} }
nextPage = result.getNextPage(); nextPage = result.getNextPage();
append(extractListItems(result.getRelatedItems())); append(extractListItems(result.getRelatedItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList())));
fetchReactor.dispose(); fetchReactor.dispose();
fetchReactor = null; fetchReactor = null;
@ -87,7 +98,7 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
}; };
} }
SingleObserver<ListExtractor.InfoItemsPage<StreamInfoItem>> getNextPageObserver() { SingleObserver<ListExtractor.InfoItemsPage<? extends InfoItem>> getNextPageObserver() {
return new SingleObserver<>() { return new SingleObserver<>() {
@Override @Override
public void onSubscribe(@NonNull final Disposable d) { public void onSubscribe(@NonNull final Disposable d) {
@ -101,13 +112,17 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
@Override @Override
public void onSuccess( public void onSuccess(
@NonNull final ListExtractor.InfoItemsPage<StreamInfoItem> result) { @NonNull final ListExtractor.InfoItemsPage<? extends InfoItem> result) {
if (!result.hasNextPage()) { if (!result.hasNextPage()) {
isComplete = true; isComplete = true;
} }
nextPage = result.getNextPage(); nextPage = result.getNextPage();
append(extractListItems(result.getItems())); append(extractListItems(result.getItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList())));
fetchReactor.dispose(); fetchReactor.dispose();
fetchReactor = null; fetchReactor = null;

View file

@ -1,47 +0,0 @@
package org.schabi.newpipe.player.playqueue;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.util.ExtractorHelper;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class ChannelPlayQueue extends AbstractInfoPlayQueue<ChannelInfo> {
public ChannelPlayQueue(final ChannelInfo info) {
super(info);
}
public ChannelPlayQueue(final int serviceId,
final String url,
final Page nextPage,
final List<StreamInfoItem> streams,
final int index) {
super(serviceId, url, nextPage, streams, index);
}
@Override
protected String getTag() {
return "ChannelPlayQueue@" + Integer.toHexString(hashCode());
}
@Override
public void fetch() {
if (this.isInitial) {
ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getHeadListObserver());
} else {
ExtractorHelper.getMoreChannelItems(this.serviceId, this.baseUrl, this.nextPage)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getNextPageObserver());
}
}
}

View file

@ -0,0 +1,53 @@
package org.schabi.newpipe.player.playqueue;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.util.ExtractorHelper;
import java.util.Collections;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue<ChannelTabInfo> {
final ListLinkHandler linkHandler;
public ChannelTabPlayQueue(final int serviceId,
final ListLinkHandler linkHandler,
final Page nextPage,
final List<StreamInfoItem> streams,
final int index) {
super(serviceId, linkHandler.getUrl(), nextPage, streams, index);
this.linkHandler = linkHandler;
}
public ChannelTabPlayQueue(final int serviceId,
final ListLinkHandler linkHandler) {
this(serviceId, linkHandler, null, Collections.emptyList(), 0);
}
@Override
protected String getTag() {
return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode());
}
@Override
public void fetch() {
if (isInitial) {
ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getHeadListObserver());
} else {
ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getNextPageObserver());
}
}
}

View file

@ -44,21 +44,11 @@ public final class NewPipeSettings {
private NewPipeSettings() { } private NewPipeSettings() { }
public static void initSettings(final Context context) { public static void initSettings(final Context context) {
// check if there are entries in the prefs to determine whether this is the first app run // check if the last used preference version is set
Boolean isFirstRun = null; // to determine whether this is the first app run
final Set<String> prefsKeys = PreferenceManager.getDefaultSharedPreferences(context) final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(context)
.getAll().keySet(); .getInt(context.getString(R.string.last_used_preferences_version), -1);
for (final String key: prefsKeys) { final boolean isFirstRun = lastUsedPrefVersion == -1;
// ACRA stores some info in the prefs during app initialization
// which happens before this method is called. Therefore ignore ACRA-related keys.
if (!key.toLowerCase().startsWith("acra")) {
isFirstRun = false;
break;
}
}
if (isFirstRun == null) {
isFirstRun = true;
}
// first run migrations, then setDefaultValues, since the latter requires the correct types // first run migrations, then setDefaultValues, since the latter requires the correct types
SettingMigrations.runMigrationsIfNeeded(context, isFirstRun); SettingMigrations.runMigrationsIfNeeded(context, isFirstRun);

View file

@ -0,0 +1,151 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.StringRes;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import java.util.List;
import java.util.Set;
public final class ChannelTabHelper {
private ChannelTabHelper() {
}
/**
* @param tab the channel tab to check
* @return whether the tab should contain (playable) streams or not
*/
public static boolean isStreamsTab(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
case ChannelTabs.TRACKS:
case ChannelTabs.SHORTS:
case ChannelTabs.LIVESTREAMS:
return true;
default:
return false;
}
}
/**
* @param tab the channel tab link handler to check
* @return whether the tab should contain (playable) streams or not
*/
public static boolean isStreamsTab(final ListLinkHandler tab) {
final List<String> contentFilters = tab.getContentFilters();
if (contentFilters.isEmpty()) {
return false; // this should never happen, but check just to be sure
} else {
return isStreamsTab(contentFilters.get(0));
}
}
@StringRes
private static int getShowTabKey(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
return R.string.show_channel_tabs_videos;
case ChannelTabs.TRACKS:
return R.string.show_channel_tabs_tracks;
case ChannelTabs.SHORTS:
return R.string.show_channel_tabs_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.show_channel_tabs_livestreams;
case ChannelTabs.CHANNELS:
return R.string.show_channel_tabs_channels;
case ChannelTabs.PLAYLISTS:
return R.string.show_channel_tabs_playlists;
case ChannelTabs.ALBUMS:
return R.string.show_channel_tabs_albums;
default:
return -1;
}
}
@StringRes
private static int getFetchFeedTabKey(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
return R.string.fetch_channel_tabs_videos;
case ChannelTabs.TRACKS:
return R.string.fetch_channel_tabs_tracks;
case ChannelTabs.SHORTS:
return R.string.fetch_channel_tabs_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.fetch_channel_tabs_livestreams;
default:
return -1;
}
}
@StringRes
public static int getTranslationKey(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
return R.string.channel_tab_videos;
case ChannelTabs.TRACKS:
return R.string.channel_tab_tracks;
case ChannelTabs.SHORTS:
return R.string.channel_tab_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.channel_tab_livestreams;
case ChannelTabs.CHANNELS:
return R.string.channel_tab_channels;
case ChannelTabs.PLAYLISTS:
return R.string.channel_tab_playlists;
case ChannelTabs.ALBUMS:
return R.string.channel_tab_albums;
default:
return R.string.unknown_content;
}
}
public static boolean showChannelTab(final Context context,
final SharedPreferences sharedPreferences,
@StringRes final int key) {
final Set<String> enabledTabs = sharedPreferences.getStringSet(
context.getString(R.string.show_channel_tabs_key), null);
if (enabledTabs == null) {
return true; // default to true
} else {
return enabledTabs.contains(context.getString(key));
}
}
public static boolean showChannelTab(final Context context,
final SharedPreferences sharedPreferences,
final String tab) {
final int key = ChannelTabHelper.getShowTabKey(tab);
if (key == -1) {
return false;
}
return showChannelTab(context, sharedPreferences, key);
}
public static boolean fetchFeedChannelTab(final Context context,
final SharedPreferences sharedPreferences,
final ListLinkHandler tab) {
final List<String> contentFilters = tab.getContentFilters();
if (contentFilters.isEmpty()) {
return false; // this should never happen, but check just to be sure
}
final int key = ChannelTabHelper.getFetchFeedTabKey(contentFilters.get(0));
if (key == -1) {
return false;
}
final Set<String> enabledTabs = sharedPreferences.getStringSet(
context.getString(R.string.feed_fetch_channel_tabs_key), null);
if (enabledTabs == null) {
return true; // default to true
} else {
return enabledTabs.contains(context.getString(key));
}
}
}

View file

@ -36,17 +36,15 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.MetaInfo; import org.schabi.newpipe.extractor.MetaInfo;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.feed.FeedExtractor;
import org.schabi.newpipe.extractor.feed.FeedInfo;
import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
@ -127,28 +125,24 @@ public final class ExtractorHelper {
ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); ChannelInfo.getInfo(NewPipe.getService(serviceId), url)));
} }
public static Single<InfoItemsPage<StreamInfoItem>> getMoreChannelItems(final int serviceId, public static Single<ChannelTabInfo> getChannelTab(final int serviceId,
final String url, final ListLinkHandler listLinkHandler,
final Page nextPage) { final boolean forceLoad) {
checkServiceId(serviceId); checkServiceId(serviceId);
return Single.fromCallable(() -> return checkCache(forceLoad, serviceId,
ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); listLinkHandler.getUrl(), InfoItem.InfoType.CHANNEL,
Single.fromCallable(() ->
ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler)));
} }
public static Single<ListInfo<StreamInfoItem>> getFeedInfoFallbackToChannelInfo( public static Single<InfoItemsPage<InfoItem>> getMoreChannelTabItems(
final int serviceId, final String url) { final int serviceId,
final Maybe<ListInfo<StreamInfoItem>> maybeFeedInfo = Maybe.fromCallable(() -> { final ListLinkHandler listLinkHandler,
final StreamingService service = NewPipe.getService(serviceId); final Page nextPage) {
final FeedExtractor feedExtractor = service.getFeedExtractor(url); checkServiceId(serviceId);
return Single.fromCallable(() ->
if (feedExtractor == null) { ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId),
return null; listLinkHandler, nextPage));
}
return FeedInfo.getInfo(feedExtractor);
});
return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true));
} }
public static Single<CommentsInfo> getCommentsInfo(final int serviceId, final String url, public static Single<CommentsInfo> getCommentsInfo(final int serviceId, final String url,
@ -229,7 +223,7 @@ public final class ExtractorHelper {
load = actualLoadFromNetwork; load = actualLoadFromNetwork;
} else { } else {
load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType), load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType),
actualLoadFromNetwork.toMaybe()) actualLoadFromNetwork.toMaybe())
.firstElement() // Take the first valid .firstElement() // Take the first valid
.toSingle(); .toSingle();
} }
@ -240,10 +234,10 @@ public final class ExtractorHelper {
/** /**
* Default implementation uses the {@link InfoCache} to get cached results. * Default implementation uses the {@link InfoCache} to get cached results.
* *
* @param <I> the item type's class that extends {@link Info} * @param <I> the item type's class that extends {@link Info}
* @param serviceId the service to load from * @param serviceId the service to load from
* @param url the URL to load * @param url the URL to load
* @param infoType the {@link InfoItem.InfoType} of the item * @param infoType the {@link InfoItem.InfoType} of the item
* @return a {@link Single} that loads the item * @return a {@link Single} that loads the item
*/ */
private static <I extends Info> Maybe<I> loadFromCache(final int serviceId, final String url, private static <I extends Info> Maybe<I> loadFromCache(final int serviceId, final String url,
@ -274,11 +268,12 @@ public final class ExtractorHelper {
* Formats the text contained in the meta info list as HTML and puts it into the text view, * Formats the text contained in the meta info list as HTML and puts it into the text view,
* while also making the separator visible. If the list is null or empty, or the user chose not * while also making the separator visible. If the list is null or empty, or the user chose not
* to see meta information, both the text view and the separator are hidden * to see meta information, both the text view and the separator are hidden
* @param metaInfos a list of meta information, can be null or empty *
* @param metaInfoTextView the text view in which to show the formatted HTML * @param metaInfos a list of meta information, can be null or empty
* @param metaInfoTextView the text view in which to show the formatted HTML
* @param metaInfoSeparator another view to be shown or hidden accordingly to the text view * @param metaInfoSeparator another view to be shown or hidden accordingly to the text view
* @param disposables disposables created by the method are added here and their lifecycle * @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class * should be handled by the calling class
*/ */
public static void showMetaInfoInTextView(@Nullable final List<MetaInfo> metaInfos, public static void showMetaInfoInTextView(@Nullable final List<MetaInfo> metaInfos,
final TextView metaInfoTextView, final TextView metaInfoTextView,
@ -287,7 +282,7 @@ public final class ExtractorHelper {
final Context context = metaInfoTextView.getContext(); final Context context = metaInfoTextView.getContext();
if (metaInfos == null || metaInfos.isEmpty() if (metaInfos == null || metaInfos.isEmpty()
|| !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
context.getString(R.string.show_meta_info_key), true)) { context.getString(R.string.show_meta_info_key), true)) {
metaInfoTextView.setVisibility(View.GONE); metaInfoTextView.setVisibility(View.GONE);
metaInfoSeparator.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE);

View file

@ -0,0 +1,90 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.player.PlayerType;
/**
* Utility class for play buttons and their respective click listeners.
*/
public final class PlayButtonHelper {
private PlayButtonHelper() {
// utility class
}
/**
* Initialize {@link android.view.View.OnClickListener OnClickListener}
* and {@link android.view.View.OnLongClickListener OnLongClickListener} for playlist control
* buttons defined in {@link R.layout#playlist_control}.
*
* @param activity The activity to use for the {@link android.widget.Toast Toast}.
* @param playlistControlBinding The binding of the
* {@link R.layout#playlist_control playlist control layout}.
* @param fragment The fragment to get the play queue from.
*/
public static void initPlaylistControlClickListener(
@NonNull final AppCompatActivity activity,
@NonNull final PlaylistControlBinding playlistControlBinding,
@NonNull final PlaylistControlViewHolder fragment) {
// click listener
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> {
NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue());
showHoldToAppendToastIfNeeded(activity);
});
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> {
NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false);
showHoldToAppendToastIfNeeded(activity);
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> {
NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false);
showHoldToAppendToastIfNeeded(activity);
});
// long click listener
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO);
return true;
});
}
/**
* Show the "hold to append" toast if the corresponding preference is enabled.
*
* @param context The context to show the toast.
*/
private static void showHoldToAppendToastIfNeeded(@NonNull final Context context) {
if (shouldShowHoldToAppendTip(context)) {
Toast.makeText(context, R.string.hold_to_append, Toast.LENGTH_SHORT).show();
}
}
/**
* Check if the "hold to append" toast should be shown.
*
* <p>
* The tip is shown if the corresponding preference is enabled.
* This is the default behaviour.
* </p>
*
* @param context The context to get the preference.
* @return {@code true} if the tip should be shown, {@code false} otherwise.
*/
public static boolean shouldShowHoldToAppendTip(@NonNull final Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.show_hold_to_append_key), true);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -1,131 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/contrast_background_color">
<RelativeLayout
android:id="@+id/channel_metadata"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/channel_banner_image"
android:layout_width="match_parent"
android:layout_height="70dp"
android:background="@android:color/black"
android:fitsSystemWindows="true"
android:scaleType="fitCenter"
android:src="@drawable/placeholder_channel_banner"
tools:ignore="ContentDescription" />
<FrameLayout
android:id="@+id/avatars_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginTop="50dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/channel_avatar_view"
android:layout_width="@dimen/channel_avatar_size"
android:layout_height="@dimen/channel_avatar_size"
android:padding="1dp"
android:src="@drawable/placeholder_person"
app:shapeAppearance="@style/CircularImageView"
app:strokeColor="#ffffff"
app:strokeWidth="2dp" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/sub_channel_avatar_view"
android:layout_width="@dimen/sub_channel_avatar_size"
android:layout_height="@dimen/sub_channel_avatar_size"
android:layout_gravity="bottom|right"
android:padding="1dp"
android:src="@drawable/placeholder_person"
android:visibility="gone"
app:shapeAppearance="@style/CircularImageView"
app:strokeColor="#ffffff"
app:strokeWidth="2dp"
tools:ignore="RtlHardcoded"
tools:visibility="visible" />
</FrameLayout>
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/channel_title_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/channel_banner_image"
android:layout_marginLeft="8dp"
android:layout_marginTop="6dp"
android:layout_marginRight="8dp"
android:layout_toLeftOf="@id/channel_subscribe_button"
android:layout_toRightOf="@id/avatars_layout"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/video_item_detail_title_text_size"
tools:ignore="RtlHardcoded"
tools:text="Lorem ipsum dolor" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/sub_channel_title_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/channel_title_view"
android:layout_alignLeft="@id/channel_title_view"
android:layout_alignRight="@id/channel_title_view"
android:ellipsize="end"
android:gravity="center|left"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="12dp"
tools:ignore="RtlHardcoded"
tools:layout_below="@id/channel_title_view"
tools:text="Lorem ipsum dolor" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/channel_subscriber_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/sub_channel_title_view"
android:layout_alignLeft="@id/channel_title_view"
android:layout_alignRight="@id/channel_title_view"
android:ellipsize="end"
android:maxLines="2"
android:textSize="@dimen/channel_subscribers_text_size"
android:visibility="gone"
tools:ignore="RtlHardcoded"
tools:text="123,141,411 subscribers"
tools:visibility="visible" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/channel_subscribe_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/channel_banner_image"
android:layout_alignParentRight="true"
android:layout_gravity="center_vertical|right"
android:layout_marginRight="2dp"
android:text="@string/subscribe_button_title"
android:textSize="@dimen/channel_rss_title_size"
android:theme="@style/ServiceColoredButton"
android:visibility="gone"
tools:ignore="RtlHardcoded"
tools:visibility="visible" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/channel_metadata">
<include
android:id="@+id/playlist_control"
layout="@layout/playlist_control" />
</LinearLayout>
</RelativeLayout>

View file

@ -1,80 +1,214 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <!-- since ToolbarTheme sets the tint to icons, it would make images all white,
android:id="@+id/items_list" therefore app:tint="@null" is used to undo that setting -->
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ToolbarTheme"
app:elevation="0dp">
<org.schabi.newpipe.views.CustomCollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/channel_metadata"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:scaleType="centerCrop"
app:layout_collapseMode="parallax">
<ImageView
android:id="@+id/channel_banner_image"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:maxHeight="70dp"
android:scaleType="fitCenter"
android:src="@drawable/placeholder_channel_banner"
app:layout_constraintTop_toTopOf="parent"
app:tint="@null"
tools:ignore="ContentDescription" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/channel_avatar_view"
android:layout_width="@dimen/channel_avatar_size"
android:layout_height="@dimen/channel_avatar_size"
android:layout_marginVertical="8dp"
android:layout_marginStart="8dp"
android:padding="1dp"
android:src="@drawable/placeholder_person"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1.0"
app:shapeAppearance="@style/CircularImageView"
app:strokeColor="#ffffff"
app:strokeWidth="2dp"
app:tint="@null" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/sub_channel_avatar_view"
android:layout_width="@dimen/sub_channel_avatar_size"
android:layout_height="@dimen/sub_channel_avatar_size"
android:padding="1dp"
android:src="@drawable/placeholder_person"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/channel_avatar_view"
app:layout_constraintEnd_toEndOf="@id/channel_avatar_view"
app:shapeAppearance="@style/CircularImageView"
app:strokeColor="#ffffff"
app:strokeWidth="2dp"
app:tint="@null"
tools:visibility="visible" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/channel_title_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="4dp"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="16sp"
app:layout_constraintBottom_toTopOf="@+id/sub_channel_title_view"
app:layout_constraintEnd_toStartOf="@+id/channel_subscribe_button"
app:layout_constraintStart_toEndOf="@+id/channel_avatar_view"
app:layout_constraintTop_toBottomOf="@+id/channel_banner_image"
tools:text="@tools:sample/lorem[10]" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/sub_channel_title_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="4dp"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="14sp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/channel_subscriber_view"
app:layout_constraintEnd_toStartOf="@+id/channel_subscribe_button"
app:layout_constraintStart_toEndOf="@+id/channel_avatar_view"
app:layout_constraintTop_toBottomOf="@+id/channel_title_view"
tools:text="@tools:sample/lorem[10]"
tools:visibility="visible" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/channel_subscriber_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="8dp"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/channel_subscribe_button"
app:layout_constraintStart_toEndOf="@+id/channel_avatar_view"
app:layout_constraintTop_toBottomOf="@+id/sub_channel_title_view"
tools:text="123,141,411 subscribers" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/channel_subscribe_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/subscribe_button_title"
android:textSize="@dimen/channel_rss_title_size"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/channel_banner_image" />
</androidx.constraintlayout.widget.ConstraintLayout>
</org.schabi.newpipe.views.CustomCollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<RelativeLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:scrollbars="vertical" android:layout_below="@id/app_bar_layout"
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_centerInParent="true"
android:orientation="vertical" android:orientation="vertical"
android:paddingTop="90dp" app:layout_behavior="@string/appbar_scrolling_view_behavior">
android:visibility="gone"
tools:visibility="visible">
<org.schabi.newpipe.views.NewPipeTextView <com.google.android.material.tabs.TabLayout
android:id="@+id/channel_kaomoji" android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
app:tabGravity="fill"
app:tabIndicatorColor="@color/white"
app:tabMode="scrollable"
app:tabRippleColor="@color/white"
app:tabTextColor="@color/white" />
<androidx.viewpager.widget.ViewPager
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/tab_layout" />
<ProgressBar
android:id="@+id/loading_progress_bar"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_centerInParent="true"
android:layout_marginBottom="10dp" android:indeterminate="true"
android:fontFamily="monospace" android:visibility="gone"
android:text="(╯°-°)╯" tools:visibility="visible" />
android:textSize="35sp"
tools:ignore="HardcodedText,UnusedAttribute" />
<org.schabi.newpipe.views.NewPipeTextView <LinearLayout
android:id="@+id/empty_state_message" android:id="@+id/empty_state_view"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_centerInParent="true"
android:text="@string/empty_view_no_videos" android:orientation="vertical"
android:textSize="24sp" /> android:paddingTop="90dp"
android:visibility="gone"
tools:visibility="visible">
<org.schabi.newpipe.views.NewPipeTextView <org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/error_content_not_supported" android:id="@+id/channel_kaomoji"
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" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/error_content_not_supported"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/content_not_supported"
android:textSize="15sp"
android:visibility="gone" />
</LinearLayout>
<!--ERROR PANEL-->
<include
android:id="@+id/error_panel"
layout="@layout/error_panel"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="20dp" android:layout_centerInParent="true"
android:text="@string/content_not_supported" android:layout_marginTop="50dp"
android:textSize="15sp" android:visibility="gone"
android:visibility="gone" /> tools:visibility="visible" />
</RelativeLayout>
</LinearLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
<!--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" />
</RelativeLayout>

View file

@ -0,0 +1,50 @@
<?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" />
<include
android:id="@+id/empty_state_view"
layout="@layout/list_empty_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:gravity="center"
android:visibility="gone"
tools:visibility="visible" />
<!--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" />
</RelativeLayout>

View file

@ -0,0 +1,71 @@
<?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_centerInParent="true"
android:orientation="vertical"
android:paddingTop="90dp"
android:visibility="gone"
tools:visibility="visible">
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/channel_kaomoji"
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:id="@+id/channel_no_videos"
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" />
</RelativeLayout>

View file

@ -8,6 +8,7 @@
android:scrollbars="vertical"> android:scrollbars="vertical">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraint_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:animateLayoutChanges="true"> android:animateLayoutChanges="true">

View file

@ -620,6 +620,7 @@
<string name="metadata_thumbnail_url">Vorschaubild-URL</string> <string name="metadata_thumbnail_url">Vorschaubild-URL</string>
<string name="metadata_host">Server</string> <string name="metadata_host">Server</string>
<string name="metadata_support">Unterstützung</string> <string name="metadata_support">Unterstützung</string>
<string name="metadata_subscribers">Abonnenten</string>
<string name="description_select_disable">Auswählen von Text in der Beschreibung deaktivieren</string> <string name="description_select_disable">Auswählen von Text in der Beschreibung deaktivieren</string>
<string name="description_select_enable">Auswählen von Text in der Beschreibung aktivieren</string> <string name="description_select_enable">Auswählen von Text in der Beschreibung aktivieren</string>
<string name="description_select_note">Du kannst nun Text innerhalb der Beschreibung auswählen. Beachte, dass die Seite flackern kann und Links im Auswahlmodus möglicherweise nicht anklickbar sind.</string> <string name="description_select_note">Du kannst nun Text innerhalb der Beschreibung auswählen. Beachte, dass die Seite flackern kann und Links im Auswahlmodus möglicherweise nicht anklickbar sind.</string>
@ -766,4 +767,12 @@
<string name="disable_media_tunneling_automatic_info">Das Media-Tunneling wurde auf dem Gerät standardmäßig deaktiviert, da das Gerätemodell diese Funktion bekanntermaßen nicht unterstützt.</string> <string name="disable_media_tunneling_automatic_info">Das Media-Tunneling wurde auf dem Gerät standardmäßig deaktiviert, da das Gerätemodell diese Funktion bekanntermaßen nicht unterstützt.</string>
<string name="no_live_streams">Keine Live-Streams</string> <string name="no_live_streams">Keine Live-Streams</string>
<string name="no_streams">Keine Streams</string> <string name="no_streams">Keine Streams</string>
<string name="channel_tab_videos">Videos</string>
<string name="channel_tab_livestreams">Live</string>
<string name="channel_tab_shorts">Shorts</string>
<string name="channel_tab_playlists">Wiedergabelisten</string>
<string name="channel_tab_channels">Kanäle</string>
<string name="channel_tab_albums">Alben</string>
<string name="show_channel_tabs">Tabs auf den Kanalseiten</string>
<string name="show_channel_tabs_summary">Welche Tabs auf den Kanalseiten angezeigt werden</string>
</resources> </resources>

View file

@ -32,7 +32,6 @@
<dimen name="video_item_detail_sub_channel_text_size">16sp</dimen> <dimen name="video_item_detail_sub_channel_text_size">16sp</dimen>
<dimen name="video_item_detail_upload_date_text_size">14sp</dimen> <dimen name="video_item_detail_upload_date_text_size">14sp</dimen>
<dimen name="video_item_detail_description_text_size">14sp</dimen> <dimen name="video_item_detail_description_text_size">14sp</dimen>
<dimen name="channel_subscribers_text_size">14sp</dimen>
<dimen name="channel_rss_title_size">14sp</dimen> <dimen name="channel_rss_title_size">14sp</dimen>
<!-- Elements Size --> <!-- Elements Size -->
<dimen name="video_item_detail_uploader_image_size">42dp</dimen> <dimen name="video_item_detail_uploader_image_size">42dp</dimen>

View file

@ -75,7 +75,6 @@
<dimen name="video_item_detail_sub_channel_text_size">14sp</dimen> <dimen name="video_item_detail_sub_channel_text_size">14sp</dimen>
<dimen name="video_item_detail_upload_date_text_size">13sp</dimen> <dimen name="video_item_detail_upload_date_text_size">13sp</dimen>
<dimen name="video_item_detail_description_text_size">13sp</dimen> <dimen name="video_item_detail_description_text_size">13sp</dimen>
<dimen name="channel_subscribers_text_size">12sp</dimen>
<dimen name="channel_rss_title_size">12sp</dimen> <dimen name="channel_rss_title_size">12sp</dimen>
<!-- Elements Size --> <!-- Elements Size -->
<dimen name="video_item_detail_uploader_image_size">32dp</dimen> <dimen name="video_item_detail_uploader_image_size">32dp</dimen>

View file

@ -274,6 +274,35 @@
<string name="main_tabs_position_key">main_tabs_position</string> <string name="main_tabs_position_key">main_tabs_position</string>
<!-- Content & History --> <!-- Content & History -->
<string name="show_channel_tabs_key">channel_tabs</string>
<string name="show_channel_tabs_videos">show_channel_tabs_videos</string>
<string name="show_channel_tabs_tracks">show_channel_tabs_tracks</string>
<string name="show_channel_tabs_shorts">show_channel_tabs_shorts</string>
<string name="show_channel_tabs_livestreams">show_channel_tabs_livestreams</string>
<string name="show_channel_tabs_channels">show_channel_tabs_channels</string>
<string name="show_channel_tabs_playlists">show_channel_tabs_playlists</string>
<string name="show_channel_tabs_albums">show_channel_tabs_albums</string>
<string name="show_channel_tabs_about">show_channel_tabs_about</string>
<string-array name="show_channel_tabs_value_list">
<item>@string/show_channel_tabs_videos</item>
<item>@string/show_channel_tabs_tracks</item>
<item>@string/show_channel_tabs_shorts</item>
<item>@string/show_channel_tabs_livestreams</item>
<item>@string/show_channel_tabs_channels</item>
<item>@string/show_channel_tabs_playlists</item>
<item>@string/show_channel_tabs_albums</item>
<item>@string/show_channel_tabs_about</item>
</string-array>
<string-array name="show_channel_tabs_description_list">
<item>@string/channel_tab_videos</item>
<item>@string/channel_tab_tracks</item>
<item>@string/channel_tab_shorts</item>
<item>@string/channel_tab_livestreams</item>
<item>@string/channel_tab_channels</item>
<item>@string/channel_tab_playlists</item>
<item>@string/channel_tab_albums</item>
<item>@string/channel_tab_about</item>
</string-array>
<string name="show_search_suggestions_key">show_search_suggestions</string> <string name="show_search_suggestions_key">show_search_suggestions</string>
<string name="show_local_search_suggestions_key">show_local_search_suggestions</string> <string name="show_local_search_suggestions_key">show_local_search_suggestions</string>
<string name="show_remote_search_suggestions_key">show_remote_search_suggestions</string> <string name="show_remote_search_suggestions_key">show_remote_search_suggestions</string>
@ -343,6 +372,24 @@
</string-array> </string-array>
<string name="feed_use_dedicated_fetch_method_key">feed_use_dedicated_fetch_method</string> <string name="feed_use_dedicated_fetch_method_key">feed_use_dedicated_fetch_method</string>
<string name="feed_fetch_channel_tabs_key">feed_fetch_channel_tabs</string>
<string name="fetch_channel_tabs_videos">fetch_channel_tabs_videos</string>
<string name="fetch_channel_tabs_tracks">fetch_channel_tabs_tracks</string>
<string name="fetch_channel_tabs_shorts">fetch_channel_tabs_shorts</string>
<string name="fetch_channel_tabs_livestreams">fetch_channel_tabs_livestreams</string>
<string-array name="feed_fetch_channel_tabs_value_list">
<item>@string/fetch_channel_tabs_videos</item>
<item>@string/fetch_channel_tabs_tracks</item>
<item>@string/fetch_channel_tabs_shorts</item>
<item>@string/fetch_channel_tabs_livestreams</item>
</string-array>
<string-array name="feed_fetch_channel_tabs_description_list">
<item>@string/channel_tab_videos</item>
<item>@string/channel_tab_tracks</item>
<item>@string/channel_tab_shorts</item>
<item>@string/channel_tab_livestreams</item>
</string-array>
<string name="import_export_data_path">import_export_data_path</string> <string name="import_export_data_path">import_export_data_path</string>
<string name="import_data">import_data</string> <string name="import_data">import_data</string>
<string name="export_data">export_data</string> <string name="export_data">export_data</string>

View file

@ -714,6 +714,8 @@
\nSo the choice boils down to what you prefer: speed or precise information.</string> \nSo the choice boils down to what you prefer: speed or precise information.</string>
<string name="feed_hide_streams_title">Show the following streams</string> <string name="feed_hide_streams_title">Show the following streams</string>
<string name="feed_show_hide_streams">Show/Hide streams</string> <string name="feed_show_hide_streams">Show/Hide streams</string>
<string name="feed_fetch_channel_tabs">Fetch channel tabs</string>
<string name="feed_fetch_channel_tabs_summary">Tabs to fetch when updating the feed. This option has no effect if a channel is updated using fast mode.</string>
<string name="content_not_supported">This content is not yet supported by NewPipe.\n\nIt will hopefully be supported in a future version.</string> <string name="content_not_supported">This content is not yet supported by NewPipe.\n\nIt will hopefully be supported in a future version.</string>
<string name="detail_sub_channel_thumbnail_view_description">Channel\'s avatar thumbnail</string> <string name="detail_sub_channel_thumbnail_view_description">Channel\'s avatar thumbnail</string>
<string name="channel_created_by">Created by %s</string> <string name="channel_created_by">Created by %s</string>
@ -754,10 +756,13 @@
<string name="metadata_support">Support</string> <string name="metadata_support">Support</string>
<string name="metadata_host">Host</string> <string name="metadata_host">Host</string>
<string name="metadata_thumbnail_url">Thumbnail URL</string> <string name="metadata_thumbnail_url">Thumbnail URL</string>
<string name="metadata_avatar_url">Avatar URL</string>
<string name="metadata_banner_url">Banner URL</string>
<string name="metadata_privacy_public">Public</string> <string name="metadata_privacy_public">Public</string>
<string name="metadata_privacy_unlisted">Unlisted</string> <string name="metadata_privacy_unlisted">Unlisted</string>
<string name="metadata_privacy_private">Private</string> <string name="metadata_privacy_private">Private</string>
<string name="metadata_privacy_internal">Internal</string> <string name="metadata_privacy_internal">Internal</string>
<string name="metadata_subscribers">Subscribers</string>
<string name="detail_pinned_comment_view_description">Pinned comment</string> <string name="detail_pinned_comment_view_description">Pinned comment</string>
<string name="detail_heart_img_view_description">Hearted by creator</string> <string name="detail_heart_img_view_description">Hearted by creator</string>
<string name="open_website_license">Open website</string> <string name="open_website_license">Open website</string>
@ -796,4 +801,14 @@
<string name="audio_track_type_original">original</string> <string name="audio_track_type_original">original</string>
<string name="audio_track_type_dubbed">dubbed</string> <string name="audio_track_type_dubbed">dubbed</string>
<string name="audio_track_type_descriptive">descriptive</string> <string name="audio_track_type_descriptive">descriptive</string>
<string name="channel_tab_videos">Videos</string>
<string name="channel_tab_tracks">Tracks</string>
<string name="channel_tab_shorts">Shorts</string>
<string name="channel_tab_livestreams">Live</string>
<string name="channel_tab_channels">Channels</string>
<string name="channel_tab_playlists">Playlists</string>
<string name="channel_tab_albums">Albums</string>
<string name="channel_tab_about">About</string>
<string name="show_channel_tabs">Channel tabs</string>
<string name="show_channel_tabs_summary">What tabs are shown on the channel pages</string>
</resources> </resources>

View file

@ -41,6 +41,16 @@
app:singleLineTitle="false" app:singleLineTitle="false"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<MultiSelectListPreference
android:key="@string/show_channel_tabs_key"
android:summary="@string/show_channel_tabs_summary"
android:title="@string/show_channel_tabs"
android:entries="@array/show_channel_tabs_description_list"
android:entryValues="@array/show_channel_tabs_value_list"
android:defaultValue="@array/show_channel_tabs_value_list"
app:iconSpaceReserved="false"
app:singleLineTitle="false" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.schabi.newpipe.settings.PeertubeInstanceListFragment" android:fragment="org.schabi.newpipe.settings.PeertubeInstanceListFragment"
android:key="@string/peertube_instance_setup_key" android:key="@string/peertube_instance_setup_key"
@ -152,5 +162,15 @@
app:singleLineTitle="false" app:singleLineTitle="false"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<MultiSelectListPreference
android:key="@string/feed_fetch_channel_tabs_key"
android:summary="@string/feed_fetch_channel_tabs_summary"
android:title="@string/feed_fetch_channel_tabs"
android:entries="@array/feed_fetch_channel_tabs_description_list"
android:entryValues="@array/feed_fetch_channel_tabs_value_list"
android:defaultValue="@array/feed_fetch_channel_tabs_value_list"
app:iconSpaceReserved="false"
app:singleLineTitle="false" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>