Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for channel tabs #9182

Merged
merged 50 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
6d13cf5
feat: add channel tabs
Theta-Dev Oct 23, 2022
8627efd
fix: get notified menu option on all tabs
Theta-Dev Oct 23, 2022
6d84d19
fix: handle unsupported content
Theta-Dev Oct 23, 2022
4859ab6
feat: prettier channel info page
Theta-Dev Oct 23, 2022
506e372
fix: add progress spinners
Theta-Dev Oct 23, 2022
c3d1e75
fix: scrollable channel description
Theta-Dev Oct 23, 2022
bb062f0
feat: add option to hide channel tabs
Theta-Dev Oct 23, 2022
c929f00
fix: remember selected channel tab on screen rotation
Theta-Dev Oct 23, 2022
74a8bfb
feat: add album tab
Theta-Dev Oct 23, 2022
16cd47f
fix: missing album tab key
Theta-Dev Oct 23, 2022
2c98d07
fix: cache channel data
Theta-Dev Oct 25, 2022
2c03ba2
refactor: adjustments to updated tab extractor API
Theta-Dev Nov 4, 2022
4357a34
fix: ChannelFragment: save last tab
Theta-Dev Nov 22, 2022
be548dc
fix: channel tab title not being set
Theta-Dev Nov 29, 2022
d87aa23
update NewPipeExtractor
Theta-Dev Apr 5, 2023
39b4ed0
refactor: common code from ChannelInfo/Description -> BaseInfoFragment
Theta-Dev Apr 5, 2023
88384dc
update extractor
Theta-Dev Apr 5, 2023
b7911a8
remove fragment_channel_info
Theta-Dev Apr 5, 2023
25e3031
cleanup: remove empty constructor from ChannelFragment
Theta-Dev Apr 5, 2023
c03c344
refactor: rename ChannelInfo to ChannelAbout
Theta-Dev Apr 5, 2023
193c3e5
fix: NPE in ChannelFragment::onSaveInstanceState
Theta-Dev Apr 6, 2023
e3614cb
Move channel header to collapsible app bar
Stypox Apr 12, 2023
b5893f3
fix: notification menu option disappears when switching tabs
Theta-Dev Apr 13, 2023
dfbd39e
fix: limit channel header height
Theta-Dev Apr 13, 2023
c076a0f
Channels are now an Info
Stypox Apr 14, 2023
a1e8b9b
Fix channel tabs in main page setting title themselves
Stypox Apr 14, 2023
371f986
Fix some code smells
Stypox Apr 14, 2023
753a920
feat: add playlist controls to channel tab
Theta-Dev Apr 15, 2023
a2a717b
update NPE
Theta-Dev Apr 16, 2023
28d952a
feat: filter fetched channel tabs
Theta-Dev Apr 16, 2023
dca32ef
add channel banner placeholder
Theta-Dev Apr 19, 2023
013d513
Add space above channel description (About tab)
Stypox Apr 21, 2023
1061bce
Add avatar and bannner URLs to channel About tab
Stypox Apr 21, 2023
c48e702
Improve placeholder channel banner handling
Stypox Apr 21, 2023
604419d
Make channel banner placeholder match YouTube's size
Stypox Apr 21, 2023
6b3a178
Show snackbar with feed loading errors
Stypox Apr 25, 2023
1519527
Fix loading feed when a channel tab is empty
Stypox Apr 25, 2023
6f23b56
Use consistent name for livestreams tab in settings keys
Stypox Apr 25, 2023
9e55014
Fix wrongly themed channel header
Stypox Apr 25, 2023
78b4b94
Update NewPipeExtractor and adapt imports
Stypox Aug 2, 2023
5c7c382
Add missing `@Override` annotations to setupMetadata() implementations
TobiGr Aug 22, 2023
6ab8716
Extract actual feed loading code into separate method
TobiGr Aug 22, 2023
89dc44b
Always show the About tab and support having no description
AudricV Aug 22, 2023
f2ee385
Hide the upload date element on the About tab
AudricV Aug 22, 2023
8fbc8ff
Remove unneeded German translation
AudricV Aug 22, 2023
0d9910c
Fix SubscriptionManagerTest tests
AudricV Aug 23, 2023
109d06b
Deduplicate code to initialize ClickListeners on playlist controls
TobiGr Sep 9, 2023
57eaa1b
Apply review
TobiGr Sep 18, 2023
64da7a0
Fix previous ActionBar title visible for a few miliseconds when openi…
TobiGr Sep 18, 2023
031b893
Remove unused content not supported TextView
TobiGr Sep 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ dependencies {
// 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/
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'

/** Checkstyle **/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,13 @@
import org.junit.Test;
import org.schabi.newpipe.database.AppDatabase;
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.extractor.channel.ChannelInfo;
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.TrampolineSchedulerRule;

import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;

public class SubscriptionManagerTest {
Expand Down Expand Up @@ -58,7 +52,7 @@ public void testInsert() throws ExtractionException, IOException {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown");
final SubscriptionEntity subscription = SubscriptionEntity.from(info);

manager.insertSubscription(subscription, info);
manager.insertSubscription(subscription);
final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity();

// the uid has changed, since the uid is chosen upon inserting, but the rest should match
Expand All @@ -76,7 +70,7 @@ public void testUpdateNotificationMode() throws ExtractionException, IOException
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
subscription.setNotificationMode(0);

manager.insertSubscription(subscription, info);
manager.insertSubscription(subscription);
manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1)
.blockingAwait();
final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity();
Expand All @@ -85,35 +79,4 @@ public void testUpdateNotificationMode() throws ExtractionException, IOException
assertEquals(subscription.getUrl(), anotherSubscription.getUrl());
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());
}
}
}
15 changes: 13 additions & 2 deletions app/src/main/java/org/schabi/newpipe/RouterActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,19 @@
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
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.stream.StreamInfo;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
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.PlaylistPlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
Expand Down Expand Up @@ -1022,7 +1024,16 @@ public Consumer<Info> getResultHandler(final Choice choice) {
}
playQueue = new SinglePlayQueue((StreamInfo) info);
} 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) {
playQueue = new PlaylistPlayQueue((PlaylistInfo) info);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading