Merge remote-tracking branch 'origin/1473_remove_duplicates_from_playlist' into 1473_remove_duplicates_from_playlist

This commit is contained in:
Jared Fantaye 2023-02-28 16:44:13 +01:00
commit 265de55a07
308 changed files with 5310 additions and 1395 deletions

View file

@ -235,7 +235,7 @@ public class MainActivity extends AppCompatActivity {
.setIcon(R.drawable.ic_tv);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
.setIcon(R.drawable.ic_rss_feed);
.setIcon(R.drawable.ic_subscriptions);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
.setIcon(R.drawable.ic_bookmark);

View file

@ -5,6 +5,7 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
import android.content.Context;
import android.database.Cursor;
@ -24,7 +25,8 @@ public final class NewPipeDatabase {
private static AppDatabase getDatabase(final Context context) {
return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6)
.build();
}

View file

@ -10,12 +10,14 @@ import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.RadioButton;
import android.widget.RadioGroup;
@ -31,7 +33,12 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat;
import androidx.core.math.MathUtils;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.database.stream.model.StreamEntity;
@ -80,9 +87,13 @@ import org.schabi.newpipe.util.urlfinder.UrlFinder;
import org.schabi.newpipe.views.FocusOverlayView;
import java.io.Serializable;
import java.lang.ref.Reference;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import icepick.Icepick;
import icepick.State;
@ -91,7 +102,6 @@ import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
@ -111,12 +121,57 @@ public class RouterActivity extends AppCompatActivity {
private boolean selectionIsDownload = false;
private boolean selectionIsAddToPlaylist = false;
private AlertDialog alertDialogChoice = null;
private FragmentManager.FragmentLifecycleCallbacks dismissListener = null;
@Override
protected void onCreate(final Bundle savedInstanceState) {
ThemeHelper.setDayNightMode(this);
setTheme(ThemeHelper.isLightThemeSelected(this)
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
Localization.assureCorrectAppLanguage(this);
// Pass-through touch events to background activities
// so that our transparent window won't lock UI in the mean time
// network request is underway before showing PlaylistDialog or DownloadDialog
// (ref: https://stackoverflow.com/a/10606141)
getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
// Android never fails to impress us with a list of new restrictions per API.
// Starting with S (Android 12) one of the prerequisite conditions has to be met
// before the FLAG_NOT_TOUCHABLE flag is allowed to kick in:
// @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
// For our present purpose it seems we can just set LayoutParams.alpha to 0
// on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs
final WindowManager.LayoutParams params = getWindow().getAttributes();
params.alpha = 0f;
getWindow().setAttributes(params);
super.onCreate(savedInstanceState);
Icepick.restoreInstanceState(this, savedInstanceState);
// FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates
// We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments
// but those callbacks won't survive a config change
// Try an alternate approach to hook into FragmentManager instead, to that effect
// (ref: https://stackoverflow.com/a/44028453)
final FragmentManager fm = getSupportFragmentManager();
if (dismissListener == null) {
dismissListener = new FragmentManager.FragmentLifecycleCallbacks() {
@Override
public void onFragmentDestroyed(@NonNull final FragmentManager fm,
@NonNull final Fragment f) {
super.onFragmentDestroyed(fm, f);
if (f instanceof DialogFragment && fm.getFragments().isEmpty()) {
// No more DialogFragments, we're done
finish();
}
}
};
}
fm.registerFragmentLifecycleCallbacks(dismissListener, false);
if (TextUtils.isEmpty(currentUrl)) {
currentUrl = getUrl(getIntent());
@ -125,11 +180,6 @@ public class RouterActivity extends AppCompatActivity {
finish();
}
}
ThemeHelper.setDayNightMode(this);
setTheme(ThemeHelper.isLightThemeSelected(this)
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
Localization.assureCorrectAppLanguage(this);
}
@Override
@ -151,16 +201,34 @@ public class RouterActivity extends AppCompatActivity {
protected void onStart() {
super.onStart();
handleUrl(currentUrl);
// Don't overlap the DialogFragment after rotating the screen
// If there's no DialogFragment, we're either starting afresh
// or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change
if (getSupportFragmentManager().getFragments().isEmpty()) {
// Start over from scratch
handleUrl(currentUrl);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (dismissListener != null) {
getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener);
}
disposables.clear();
}
@Override
public void finish() {
// allow the activity to recreate in case orientation changes
if (!isChangingConfigurations()) {
super.finish();
}
}
private void handleUrl(final String url) {
disposables.add(Observable
.fromCallable(() -> {
@ -240,7 +308,7 @@ public class RouterActivity extends AppCompatActivity {
}
}
private void showUnsupportedUrlDialog(final String url) {
protected void showUnsupportedUrlDialog(final String url) {
final Context context = getThemeWrapperContext();
new AlertDialog.Builder(context)
.setTitle(R.string.unsupported_url)
@ -527,7 +595,7 @@ public class RouterActivity extends AppCompatActivity {
return returnedItems;
}
private Context getThemeWrapperContext() {
protected Context getThemeWrapperContext() {
return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this)
? R.style.LightTheme : R.style.DarkTheme);
}
@ -563,8 +631,7 @@ public class RouterActivity extends AppCompatActivity {
}
if (selectedChoiceKey.equals(getString(R.string.popup_player_key))
&& !PermissionHelper.isPopupEnabled(this)) {
PermissionHelper.showPopupEnablementToast(this);
&& !PermissionHelper.isPopupEnabledElseAsk(this)) {
finish();
return;
}
@ -634,54 +701,179 @@ public class RouterActivity extends AppCompatActivity {
return playerType == null || playerType == PlayerType.MAIN;
}
private void openAddToPlaylistDialog() {
// Getting the stream info usually takes a moment
// Notifying the user here to ensure that no confusion arises
Toast.makeText(
getApplicationContext(),
getString(R.string.processing_may_take_a_moment),
Toast.LENGTH_SHORT)
.show();
public static class PersistentFragment extends Fragment {
private WeakReference<AppCompatActivity> weakContext;
private final CompositeDisposable disposables = new CompositeDisposable();
private int running = 0;
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
info -> PlaylistDialog.createCorrespondingDialog(
getThemeWrapperContext(),
List.of(new StreamEntity(info)),
playlistDialog -> {
playlistDialog.setOnDismissListener(dialog -> finish());
private synchronized void inFlight(final boolean started) {
if (started) {
running++;
} else {
running--;
if (running <= 0) {
getActivityContext().ifPresent(context -> context.getSupportFragmentManager()
.beginTransaction().remove(this).commit());
}
}
}
playlistDialog.show(
this.getSupportFragmentManager(),
"addToPlaylistDialog"
);
}
),
throwable -> handleError(this, new ErrorInfo(
throwable,
UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist",
currentService.getServiceId())
)
)
);
@Override
public void onAttach(@NonNull final Context activityContext) {
super.onAttach(activityContext);
weakContext = new WeakReference<>((AppCompatActivity) activityContext);
}
@Override
public void onDetach() {
super.onDetach();
weakContext = null;
}
@SuppressWarnings("deprecation")
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
@Override
public void onDestroy() {
super.onDestroy();
disposables.clear();
}
/**
* @return the activity context, if there is one and the activity is not finishing
*/
private Optional<AppCompatActivity> getActivityContext() {
return Optional.ofNullable(weakContext)
.map(Reference::get)
.filter(context -> !context.isFinishing());
}
// guard against IllegalStateException in calling DialogFragment.show() whilst in background
// (which could happen, say, when the user pressed the home button while waiting for
// the network request to return) when it internally calls FragmentTransaction.commit()
// after the FragmentManager has saved its states (isStateSaved() == true)
// (ref: https://stackoverflow.com/a/39813506)
private void runOnVisible(final Consumer<AppCompatActivity> runnable) {
getActivityContext().ifPresentOrElse(context -> {
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
context.runOnUiThread(() -> {
runnable.accept(context);
inFlight(false);
});
} else {
getLifecycle().addObserver(new DefaultLifecycleObserver() {
@Override
public void onResume(@NonNull final LifecycleOwner owner) {
getLifecycle().removeObserver(this);
getActivityContext().ifPresentOrElse(context ->
context.runOnUiThread(() -> {
runnable.accept(context);
inFlight(false);
}),
() -> inFlight(false)
);
}
});
// this trick doesn't seem to work on Android 10+ (API 29)
// which places restrictions on starting activities from the background
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
&& !context.isChangingConfigurations()) {
// try to bring the activity back to front if minimised
final Intent i = new Intent(context, RouterActivity.class);
i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
startActivity(i);
}
}
}, () -> {
// this branch is executed if there is no activity context
inFlight(false);
});
}
<T> Single<T> pleaseWait(final Single<T> single) {
// 'abuse' ambWith() here to cancel the toast for us when the wait is over
return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context ->
context.runOnUiThread(() -> {
// Getting the stream info usually takes a moment
// Notifying the user here to ensure that no confusion arises
final Toast toast = Toast.makeText(context,
getString(R.string.processing_may_take_a_moment),
Toast.LENGTH_LONG);
toast.show();
emitter.setCancellable(toast::cancel);
}))));
}
@SuppressLint("CheckResult")
private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
inFlight(true);
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.compose(this::pleaseWait)
.subscribe(result ->
runOnVisible(ctx -> {
final FragmentManager fm = ctx.getSupportFragmentManager();
final DownloadDialog downloadDialog = new DownloadDialog(ctx, result);
// dismiss listener to be handled by FragmentManager
downloadDialog.show(fm, "downloadDialog");
}
), throwable -> runOnVisible(ctx ->
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl))));
}
private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
inFlight(true);
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.compose(this::pleaseWait)
.subscribe(
info -> getActivityContext().ifPresent(context ->
PlaylistDialog.createCorrespondingDialog(context,
List.of(new StreamEntity(info)),
playlistDialog -> runOnVisible(ctx -> {
// dismiss listener to be handled by FragmentManager
final FragmentManager fm =
ctx.getSupportFragmentManager();
playlistDialog.show(fm, "addToPlaylistDialog");
})
)),
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
throwable,
UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist",
((RouterActivity) ctx).currentService.getServiceId())
))
)
);
}
}
@SuppressLint("CheckResult")
private void openDownloadDialog() {
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
final DownloadDialog downloadDialog = new DownloadDialog(this, result);
downloadDialog.setOnDismissListener(dialog -> finish());
private void openAddToPlaylistDialog() {
getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl);
}
final FragmentManager fm = getSupportFragmentManager();
downloadDialog.show(fm, "downloadDialog");
fm.executePendingTransactions();
}, throwable -> showUnsupportedUrlDialog(currentUrl)));
private void openDownloadDialog() {
getPersistFragment().openDownloadDialog(currentServiceId, currentUrl);
}
private PersistentFragment getPersistFragment() {
final FragmentManager fm = getSupportFragmentManager();
PersistentFragment persistFragment =
(PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT");
if (persistFragment == null) {
persistFragment = new PersistentFragment();
fm.beginTransaction()
.add(persistFragment, "PERSIST_FRAGMENT")
.commitNow();
}
return persistFragment;
}
@Override

View file

@ -1,6 +1,6 @@
package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
import static org.schabi.newpipe.database.Migrations.DB_VER_6;
import androidx.room.Database;
import androidx.room.RoomDatabase;
@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class
},
version = DB_VER_5
version = DB_VER_6
)
public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db";

View file

@ -23,6 +23,7 @@ public final class Migrations {
public static final int DB_VER_3 = 3;
public static final int DB_VER_4 = 4;
public static final int DB_VER_5 = 5;
public static final int DB_VER_6 = 6;
private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG;
@ -188,6 +189,14 @@ public final class Migrations {
}
};
public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
+ "INTEGER NOT NULL DEFAULT 0");
}
};
private Migrations() {
}
}

View file

@ -0,0 +1,24 @@
package org.schabi.newpipe.database.playlist;
import androidx.room.ColumnInfo;
/**
* This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing
* how many times a specific stream is already contained inside a local playlist. Used to be able
* to grey out playlists which already contain the current stream in the playlist append dialog.
* @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String)
*/
public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained";
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
public final long timesStreamIsContained;
public PlaylistDuplicatesEntry(final long uid,
final String name,
final String thumbnailUrl,
final long streamCount,
final long timesStreamIsContained) {
super(uid, name, thumbnailUrl, streamCount);
this.timesStreamIsContained = timesStreamIsContained;
}
}

View file

@ -6,6 +6,7 @@ import androidx.room.RewriteQueriesToDropUnusedColumns;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
@ -14,6 +15,7 @@ import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
@ -25,6 +27,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JO
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@ -53,6 +57,15 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
Flowable<Integer> getMaximumIndexOf(long playlistId);
@Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_THUMBNAIL_URL + " ELSE :defaultUrl END"
+ " FROM " + STREAM_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId "
+ " LIMIT 1"
)
Flowable<String> getAutomaticThumbnailUrl(long playlistId, String defaultUrl);
@RewriteQueriesToDropUnusedColumns
@Transaction
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
@ -80,7 +93,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " FROM " + PLAYLIST_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " GROUP BY " + JOIN_PLAYLIST_ID
+ " GROUP BY " + PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
@ -101,6 +114,23 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " ORDER BY MIN(" + JOIN_INDEX + ") ASC")
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
@Transaction
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", "
+ PLAYLIST_NAME + ", "
+ PLAYLIST_TABLE + "." + PLAYLIST_THUMBNAIL_URL + ", "
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", "
+ "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS "
+ PLAYLIST_TIMES_STREAM_IS_CONTAINED
+ " FROM " + PLAYLIST_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " LEFT JOIN " + STREAM_TABLE
+ " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID
+ " AND :streamUrl = :streamUrl"
+ " GROUP BY " + JOIN_PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
}

View file

@ -15,6 +15,7 @@ public class PlaylistEntity {
public static final String PLAYLIST_ID = "uid";
public static final String PLAYLIST_NAME = "name";
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = PLAYLIST_ID)
@ -26,9 +27,14 @@ public class PlaylistEntity {
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
private String thumbnailUrl;
public PlaylistEntity(final String name, final String thumbnailUrl) {
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
private boolean isThumbnailPermanent;
public PlaylistEntity(final String name, final String thumbnailUrl,
final boolean isThumbnailPermanent) {
this.name = name;
this.thumbnailUrl = thumbnailUrl;
this.isThumbnailPermanent = isThumbnailPermanent;
}
public long getUid() {
@ -54,4 +60,13 @@ public class PlaylistEntity {
public void setThumbnailUrl(final String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}
public boolean getIsThumbnailPermanent() {
return isThumbnailPermanent;
}
public void setIsThumbnailPermanent(final boolean isThumbnailSet) {
this.isThumbnailPermanent = isThumbnailSet;
}
}

View file

@ -75,6 +75,7 @@ import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import icepick.Icepick;
import icepick.State;
@ -560,6 +561,39 @@ public class DownloadDialog extends DialogFragment
selectedSubtitleIndex = position;
break;
}
onItemSelectedSetFileName();
}
private void onItemSelectedSetFileName() {
final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName());
final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText())
.map(Object::toString)
.orElse("");
if (prevFileName.isEmpty()
|| prevFileName.equals(fileName)
|| prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) {
// only update the file name field if it was not edited by the user
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
case R.id.audio_button:
case R.id.video_button:
if (!prevFileName.equals(fileName)) {
// since the user might have switched between audio and video, the correct
// text might already be in place, so avoid resetting the cursor position
dialogBinding.fileName.setText(fileName);
}
break;
case R.id.subtitle_button:
final String setSubtitleLanguageCode = subtitleStreamsAdapter
.getItem(selectedSubtitleIndex).getLanguageTag();
// this will reset the cursor position, which is bad UX, but it can't be avoided
dialogBinding.fileName.setText(getString(
R.string.caption_file_name, fileName, setSubtitleLanguageCode));
break;
}
}
}
@Override

View file

@ -3,6 +3,8 @@ 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.utils.Utils.isBlank;
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;
@ -28,7 +30,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.TextLinkifier;
import org.schabi.newpipe.util.text.TextLinkifier;
import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -111,7 +113,10 @@ public class DescriptionFragment extends BaseFragment {
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
loadDescriptionContent();
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);
@ -122,52 +127,32 @@ public class DescriptionFragment extends BaseFragment {
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
private void loadDescriptionContent() {
final Description description = streamInfo.getDescription();
switch (description.getType()) {
case Description.HTML:
TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView,
description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo,
descriptionDisposables);
break;
case Description.MARKDOWN:
TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
description.getContent(), streamInfo, descriptionDisposables);
break;
case Description.PLAIN_TEXT: default:
TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
description.getContent(), streamInfo, descriptionDisposables);
break;
}
}
private void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
addMetadataItem(inflater, layout, false,
R.string.metadata_category, streamInfo.getCategory());
addMetadataItem(inflater, layout, false, R.string.metadata_category,
streamInfo.getCategory());
addMetadataItem(inflater, layout, false,
R.string.metadata_licence, streamInfo.getLicence());
addMetadataItem(inflater, layout, false, R.string.metadata_licence,
streamInfo.getLicence());
addPrivacyMetadataItem(inflater, layout);
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
addMetadataItem(inflater, layout, false,
R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit()));
addMetadataItem(inflater, layout, false, R.string.metadata_age_limit,
String.valueOf(streamInfo.getAgeLimit()));
}
if (streamInfo.getLanguageInfo() != null) {
addMetadataItem(inflater, layout, false,
R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage());
addMetadataItem(inflater, layout, false, R.string.metadata_language,
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
}
addMetadataItem(inflater, layout, true,
R.string.metadata_support, streamInfo.getSupportInfo());
addMetadataItem(inflater, layout, true,
R.string.metadata_host, streamInfo.getHost());
addMetadataItem(inflater, layout, true,
R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl());
addMetadataItem(inflater, layout, true, R.string.metadata_support,
streamInfo.getSupportInfo());
addMetadataItem(inflater, layout, true, R.string.metadata_host,
streamInfo.getHost());
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
streamInfo.getThumbnailUrl());
addTagsMetadataItem(inflater, layout);
}
@ -191,12 +176,14 @@ public class DescriptionFragment extends BaseFragment {
});
if (linkifyContent) {
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null,
descriptionDisposables);
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());
}
@ -245,14 +232,15 @@ public class DescriptionFragment extends BaseFragment {
case INTERNAL:
contentRes = R.string.metadata_privacy_internal;
break;
case OTHER: default:
case OTHER:
default:
contentRes = 0;
break;
}
if (contentRes != 0) {
addMetadataItem(inflater, layout, false,
R.string.metadata_privacy, getString(contentRes));
addMetadataItem(inflater, layout, false, R.string.metadata_privacy,
getString(contentRes));
}
}
}

View file

@ -27,7 +27,6 @@ import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@ -55,6 +54,9 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackException;
@ -170,13 +172,13 @@ public final class VideoDetailFragment
private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener =
(sharedPreferences, key) -> {
if (key.equals(getString(R.string.show_comments_key))) {
if (getString(R.string.show_comments_key).equals(key)) {
showComments = sharedPreferences.getBoolean(key, true);
tabSettingsChanged = true;
} else if (key.equals(getString(R.string.show_next_video_key))) {
} else if (getString(R.string.show_next_video_key).equals(key)) {
showRelatedItems = sharedPreferences.getBoolean(key, true);
tabSettingsChanged = true;
} else if (key.equals(getString(R.string.show_description_key))) {
} else if (getString(R.string.show_description_key).equals(key)) {
showDescription = sharedPreferences.getBoolean(key, true);
tabSettingsChanged = true;
}
@ -255,11 +257,10 @@ public final class VideoDetailFragment
playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
}
//noinspection SimplifyOptionalCallChains
if (playAfterConnect
|| (currentInfo != null
&& isAutoplayEnabled()
&& !playerUi.isPresent())) {
&& playerUi.isEmpty())) {
autoPlayEnabled = true; // forcefully start playing
openVideoPlayerAutoFullscreen();
}
@ -864,7 +865,8 @@ public final class VideoDetailFragment
if (playQueue == null) {
playQueue = new SinglePlayQueue(result);
}
if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) {
if (stack.isEmpty() || !stack.peek().getPlayQueue()
.equalStreams(playQueue)) {
stack.push(new StackItem(serviceId, url, title, playQueue));
}
}
@ -1067,8 +1069,7 @@ public final class VideoDetailFragment
}
private void openPopupPlayer(final boolean append) {
if (!PermissionHelper.isPopupEnabled(activity)) {
PermissionHelper.showPopupEnablementToast(activity);
if (!PermissionHelper.isPopupEnabledElseAsk(activity)) {
return;
}
@ -1174,16 +1175,15 @@ public final class VideoDetailFragment
* be reused in a few milliseconds and the flickering would be annoying.
*/
private void hideMainPlayerOnLoadingNewStream() {
//noinspection SimplifyOptionalCallChains
if (!isPlayerServiceAvailable() || !getRoot().isPresent()
|| !player.videoPlayerSelected()) {
final var root = getRoot();
if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
return;
}
removeVideoPlayerView();
if (isAutoplayEnabled()) {
playerService.stopForImmediateReusing();
getRoot().ifPresent(view -> view.setVisibility(View.GONE));
root.ifPresent(view -> view.setVisibility(View.GONE));
} else {
playerHolder.stopService();
}
@ -1780,7 +1780,7 @@ public final class VideoDetailFragment
// deleted/added items inside Channel/Playlist queue and makes possible to have
// a history of played items
@Nullable final StackItem stackPeek = stack.peek();
if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) {
if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) {
@Nullable final PlayQueueItem playQueueItem = queue.getItem();
if (playQueueItem != null) {
stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
@ -1846,7 +1846,7 @@ public final class VideoDetailFragment
// They are not equal when user watches something in popup while browsing in fragment and
// then changes screen orientation. In that case the fragment will set itself as
// a service listener and will receive initial call to onMetadataUpdate()
if (!queue.equals(playQueue)) {
if (!queue.equalStreams(playQueue)) {
return;
}
@ -1887,10 +1887,9 @@ public final class VideoDetailFragment
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {
setupBrightness();
//noinspection SimplifyOptionalCallChains
if (!isPlayerAndPlayerServiceAvailable()
|| !player.UIs().get(MainPlayerUi.class).isPresent()
|| getRoot().map(View::getParent).orElse(null) == null) {
|| player.UIs().get(MainPlayerUi.class).isEmpty()
|| getRoot().map(View::getParent).isEmpty()) {
return;
}
@ -1962,15 +1961,17 @@ public final class VideoDetailFragment
return;
}
// Prevent jumping of the player on devices with cutout
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
}
activity.getWindow().getDecorView().setSystemUiVisibility(0);
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
requireContext(), android.R.attr.colorPrimary));
final var window = activity.getWindow();
final var windowInsetsController = WindowCompat.getInsetsController(window,
window.getDecorView());
WindowCompat.setDecorFitsSystemWindows(window, true);
windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat
.BEHAVIOR_SHOW_BARS_BY_TOUCH);
windowInsetsController.show(WindowInsetsCompat.Type.systemBars());
window.setStatusBarColor(ThemeHelper.resolveColorFromAttr(requireContext(),
android.R.attr.colorPrimary));
}
private void hideSystemUi() {
@ -1982,30 +1983,19 @@ public final class VideoDetailFragment
return;
}
// Prevent jumping of the player on devices with cutout
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
}
int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
final var window = activity.getWindow();
final var windowInsetsController = WindowCompat.getInsetsController(window,
window.getDecorView());
// In multiWindow mode status bar is not transparent for devices with cutout
// if I include this flag. So without it is better in this case
final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity);
if (!isInMultiWindow) {
visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN;
}
activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
WindowCompat.setDecorFitsSystemWindows(window, false);
windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat
.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars());
if (isInMultiWindow || isFullscreen()) {
activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
if (DeviceUtils.isInMultiWindow(activity) || isFullscreen()) {
window.setStatusBarColor(Color.TRANSPARENT);
window.setNavigationBarColor(Color.TRANSPARENT);
}
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
// Listener implementation
@ -2113,7 +2103,7 @@ public final class VideoDetailFragment
final Iterator<StackItem> iterator = stack.descendingIterator();
while (iterator.hasNext()) {
final StackItem next = iterator.next();
if (next.getPlayQueue().equals(queue)) {
if (next.getPlayQueue().equalStreams(queue)) {
item = next;
break;
}
@ -2128,7 +2118,7 @@ public final class VideoDetailFragment
if (isClearingQueueConfirmationRequired(activity)
&& playerIsNotStopped()
&& activeQueue != null
&& !activeQueue.equals(playQueue)) {
&& !activeQueue.equalStreams(playQueue)) {
showClearingQueueConfirmation(onAllow);
} else {
onAllow.run();
@ -2429,23 +2419,20 @@ public final class VideoDetailFragment
// helpers to check the state of player and playerService
boolean isPlayerAvailable() {
return (player != null);
return player != null;
}
boolean isPlayerServiceAvailable() {
return (playerService != null);
return playerService != null;
}
boolean isPlayerAndPlayerServiceAvailable() {
return (player != null && playerService != null);
return player != null && playerService != null;
}
public Optional<View> getRoot() {
if (player == null) {
return Optional.empty();
}
return player.UIs().get(VideoPlayerUi.class)
return Optional.ofNullable(player)
.flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class))
.map(playerUi -> playerUi.getBinding().getRoot());
}

View file

@ -26,6 +26,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.info_list.InfoListAdapter;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
@ -91,11 +92,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
if (updateFlags != 0) {
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
final boolean useGrid = isGridLayout();
itemsList.setLayoutManager(useGrid
? getGridLayoutManager() : getListLayoutManager());
infoListAdapter.setUseGridVariant(useGrid);
infoListAdapter.notifyDataSetChanged();
refreshItemViewMode();
}
updateFlags = 0;
}
@ -221,15 +218,23 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
return lm;
}
/**
* Updates the item view mode based on user preference.
*/
private void refreshItemViewMode() {
final ItemViewMode itemViewMode = getItemViewMode();
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
? getGridLayoutManager() : getListLayoutManager());
infoListAdapter.setItemViewMode(itemViewMode);
infoListAdapter.notifyDataSetChanged();
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
final boolean useGrid = isGridLayout();
itemsList = rootView.findViewById(R.id.items_list);
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
infoListAdapter.setUseGridVariant(useGrid);
refreshItemViewMode();
final Supplier<View> listHeaderSupplier = getListHeaderSupplier();
if (listHeaderSupplier != null) {
@ -469,12 +474,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (key.equals(getString(R.string.list_view_mode_key))) {
if (getString(R.string.list_view_mode_key).equals(key)) {
updateFlags |= LIST_MODE_UPDATE_FLAG;
}
}
protected boolean isGridLayout() {
return ThemeHelper.shouldUseGridLayout(activity);
/**
* Returns preferred item view mode.
* @return ItemViewMode
*/
protected ItemViewMode getItemViewMode() {
return ThemeHelper.getItemViewMode(requireContext());
}
}

View file

@ -17,6 +17,7 @@ import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.ExtractorHelper;
@ -106,7 +107,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, Com
@NonNull final MenuInflater inflater) { }
@Override
protected boolean isGridLayout() {
return false;
protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST;
}
}

View file

@ -132,6 +132,8 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
// Is mini variant still relevant?
// Only the remote playlist screen uses it now
infoListAdapter.setUseMiniVariant(true);
}

View file

@ -19,6 +19,7 @@ import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.RelatedItemInfo;
@ -158,16 +159,19 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String s) {
if (headerBinding != null) {
headerBinding.autoplaySwitch.setChecked(
sharedPreferences.getBoolean(
getString(R.string.auto_queue_key), false));
final String key) {
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
}
}
@Override
protected boolean isGridLayout() {
return false;
protected ItemViewMode getItemViewMode() {
ItemViewMode mode = super.getItemViewMode();
// Only list mode is supported. Either List or card will be used.
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
mode = ItemViewMode.LIST;
}
return mode;
}
}

View file

@ -23,9 +23,11 @@ import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder;
@ -67,12 +69,14 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
private static final int MINI_STREAM_HOLDER_TYPE = 0x100;
private static final int STREAM_HOLDER_TYPE = 0x101;
private static final int GRID_STREAM_HOLDER_TYPE = 0x102;
private static final int CARD_STREAM_HOLDER_TYPE = 0x103;
private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200;
private static final int CHANNEL_HOLDER_TYPE = 0x201;
private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202;
private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300;
private static final int PLAYLIST_HOLDER_TYPE = 0x301;
private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302;
private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303;
private static final int MINI_COMMENT_HOLDER_TYPE = 0x400;
private static final int COMMENT_HOLDER_TYPE = 0x401;
@ -82,9 +86,10 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
private final HistoryRecordManager recordManager;
private boolean useMiniVariant = false;
private boolean useGridVariant = false;
private boolean showFooter = false;
private ItemViewMode itemMode = ItemViewMode.LIST;
private Supplier<View> headerSupplier = null;
public InfoListAdapter(final Context context) {
@ -114,8 +119,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
this.useMiniVariant = useMiniVariant;
}
public void setUseGridVariant(final boolean useGridVariant) {
this.useGridVariant = useGridVariant;
public void setItemViewMode(final ItemViewMode itemViewMode) {
this.itemMode = itemViewMode;
}
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
@ -234,14 +239,33 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
final InfoItem item = infoItemList.get(position);
switch (item.getInfoType()) {
case STREAM:
return useGridVariant ? GRID_STREAM_HOLDER_TYPE : useMiniVariant
? MINI_STREAM_HOLDER_TYPE : STREAM_HOLDER_TYPE;
if (itemMode == ItemViewMode.CARD) {
return CARD_STREAM_HOLDER_TYPE;
} else if (itemMode == ItemViewMode.GRID) {
return GRID_STREAM_HOLDER_TYPE;
} else if (useMiniVariant) {
return MINI_STREAM_HOLDER_TYPE;
} else {
return STREAM_HOLDER_TYPE;
}
case CHANNEL:
return useGridVariant ? GRID_CHANNEL_HOLDER_TYPE : useMiniVariant
? MINI_CHANNEL_HOLDER_TYPE : CHANNEL_HOLDER_TYPE;
if (itemMode == ItemViewMode.GRID) {
return GRID_CHANNEL_HOLDER_TYPE;
} else if (useMiniVariant) {
return MINI_CHANNEL_HOLDER_TYPE;
} else {
return CHANNEL_HOLDER_TYPE;
}
case PLAYLIST:
return useGridVariant ? GRID_PLAYLIST_HOLDER_TYPE : useMiniVariant
? MINI_PLAYLIST_HOLDER_TYPE : PLAYLIST_HOLDER_TYPE;
if (itemMode == ItemViewMode.CARD) {
return CARD_PLAYLIST_HOLDER_TYPE;
} else if (itemMode == ItemViewMode.GRID) {
return GRID_PLAYLIST_HOLDER_TYPE;
} else if (useMiniVariant) {
return MINI_PLAYLIST_HOLDER_TYPE;
} else {
return PLAYLIST_HOLDER_TYPE;
}
case COMMENT:
return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE;
default:
@ -274,6 +298,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE:
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE:
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE:
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE:
@ -286,6 +312,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE:
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE:
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
case MINI_COMMENT_HOLDER_TYPE:
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
case COMMENT_HOLDER_TYPE:

View file

@ -0,0 +1,23 @@
package org.schabi.newpipe.info_list;
/**
* Item view mode for streams & playlist listing screens.
*/
public enum ItemViewMode {
/**
* Default mode.
*/
AUTO,
/**
* Full width list item with thumb on the left and two line title & uploader in right.
*/
LIST,
/**
* Grid mode places two cards per row.
*/
GRID,
/**
* A full width card in phone - portrait.
*/
CARD
}

View file

@ -1,14 +1,9 @@
package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup;
import android.widget.TextView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization;
/*
* Created by Christian Schabesberger on 12.02.17.
@ -31,40 +26,7 @@ import org.schabi.newpipe.util.Localization;
*/
public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder {
private final TextView itemChannelDescriptionView;
public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_channel_item, parent);
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
super.updateFromItem(infoItem, historyRecordManager);
if (!(infoItem instanceof ChannelInfoItem)) {
return;
}
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
itemChannelDescriptionView.setText(item.getDescription());
}
@Override
protected String getDetailLine(final ChannelInfoItem item) {
String details = super.getDetailLine(item);
if (item.getStreamCount() >= 0) {
final String formattedVideoAmount = Localization.localizeStreamCount(
itemBuilder.getContext(), item.getStreamCount());
if (!details.isEmpty()) {
details += "" + formattedVideoAmount;
} else {
details = formattedVideoAmount;
}
}
return details;
}
}

View file

@ -1,21 +1,26 @@
package org.schabi.newpipe.info_list.holder;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization;
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
public final ImageView itemThumbnailView;
public final TextView itemTitleView;
private final ImageView itemThumbnailView;
private final TextView itemTitleView;
private final TextView itemAdditionalDetailView;
private final TextView itemChannelDescriptionView;
ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
@ -24,6 +29,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemTitleView = itemView.findViewById(R.id.itemTitleView);
itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails);
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
}
public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
@ -40,7 +46,14 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
itemTitleView.setText(item.getName());
itemAdditionalDetailView.setText(getDetailLine(item));
final String detailLine = getDetailLine(item);
if (detailLine == null) {
itemAdditionalDetailView.setVisibility(View.GONE);
} else {
itemAdditionalDetailView.setVisibility(View.VISIBLE);
itemAdditionalDetailView.setText(getDetailLine(item));
}
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
@ -56,14 +69,35 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
}
return true;
});
if (itemChannelDescriptionView != null) {
// itemChannelDescriptionView will be null in the mini variant
if (Utils.isBlank(item.getDescription())) {
itemChannelDescriptionView.setVisibility(View.GONE);
} else {
itemChannelDescriptionView.setVisibility(View.VISIBLE);
itemChannelDescriptionView.setText(item.getDescription());
itemChannelDescriptionView.setMaxLines(detailLine == null ? 3 : 2);
}
}
}
protected String getDetailLine(final ChannelInfoItem item) {
String details = "";
if (item.getSubscriberCount() >= 0) {
details += Localization.shortSubscriberCount(itemBuilder.getContext(),
@Nullable
private String getDetailLine(final ChannelInfoItem item) {
if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) {
return Localization.concatenateStrings(
Localization.shortSubscriberCount(itemBuilder.getContext(),
item.getSubscriberCount()),
Localization.localizeStreamCount(itemBuilder.getContext(),
item.getStreamCount()));
} else if (item.getStreamCount() >= 0) {
return Localization.localizeStreamCount(itemBuilder.getContext(),
item.getStreamCount());
} else if (item.getSubscriberCount() >= 0) {
return Localization.shortSubscriberCount(itemBuilder.getContext(),
item.getSubscriberCount());
} else {
return null;
}
return details;
}
}

View file

@ -1,9 +1,10 @@
package org.schabi.newpipe.info_list.holder;
import android.graphics.Paint;
import android.text.Layout;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
@ -11,27 +12,36 @@ import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.text.util.LinkifyCompat;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.CommentTextOnTouchListener;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.TimestampExtractor;
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.Objects;
import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private static final String TAG = "CommentsMiniIIHolder";
private static final String ELLIPSIS = "";
private static final int COMMENT_DEFAULT_LINES = 2;
private static final int COMMENT_EXPANDED_LINES = 1000;
@ -39,13 +49,18 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private final int commentHorizontalPadding;
private final int commentVerticalPadding;
private final Paint paintAtContentSize;
private final float ellipsisWidthPx;
private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView;
private final TextView itemContentView;
private final TextView itemLikesCountView;
private final TextView itemPublishedTime;
private String commentText;
private final CompositeDisposable disposables = new CompositeDisposable();
private Description commentText;
private StreamingService streamService;
private String streamUrl;
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
@ -62,6 +77,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
.getResources().getDimension(R.dimen.comments_horizontal_padding);
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
paintAtContentSize = new Paint();
paintAtContentSize.setTextSize(itemContentView.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
}
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
@ -91,18 +110,20 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
streamUrl = item.getUrl();
itemContentView.setLines(COMMENT_DEFAULT_LINES);
commentText = item.getCommentText();
itemContentView.setText(commentText, TextView.BufferType.SPANNABLE);
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
if (itemContentView.getLineCount() == 0) {
itemContentView.post(this::ellipsize);
} else {
ellipsize();
try {
streamService = NewPipe.getService(item.getServiceId());
} catch (final ExtractionException e) {
// should never happen
ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e);
Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e);
streamService = ServiceList.YouTube;
}
streamUrl = item.getUrl();
commentText = item.getCommentText();
ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
if (item.getLikeCount() >= 0) {
itemLikesCountView.setText(
@ -132,7 +153,8 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item);
} else {
ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText);
ShareUtils.copyToClipboard(itemBuilder.getContext(),
itemContentView.getText().toString());
}
return true;
});
@ -172,7 +194,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
return urls != null && urls.length != 0;
}
private void determineLinkFocus() {
private void determineMovementMethod() {
if (shouldFocusLinks()) {
allowLinkFocus();
} else {
@ -181,63 +203,73 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
}
private void ellipsize() {
boolean hasEllipsis = false;
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> {
boolean hasEllipsis = false;
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
final int endOfLastLine = itemContentView
.getLayout()
.getLineEnd(COMMENT_DEFAULT_LINES - 1);
int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2);
if (end == -1) {
end = Math.max(endOfLastLine - 2, 0);
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
// Note that converting to String removes spans (i.e. links), but that's something
// we actually want since when the text is ellipsized we want all clicks on the
// comment to expand the comment, not to open links.
final String text = itemContentView.getText().toString();
final Layout layout = itemContentView.getLayout();
final float lineWidth = layout.getLineWidth(COMMENT_DEFAULT_LINES - 1);
final float layoutWidth = layout.getWidth();
final int lineStart = layout.getLineStart(COMMENT_DEFAULT_LINES - 1);
final int lineEnd = layout.getLineEnd(COMMENT_DEFAULT_LINES - 1);
// remove characters up until there is enough space for the ellipsis
// (also summing 2 more pixels, just to be sure to avoid float rounding errors)
int end = lineEnd;
float removedCharactersWidth = 0.0f;
while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
&& end >= lineStart) {
end -= 1;
// recalculate each time to account for ligatures or other similar things
removedCharactersWidth = paintAtContentSize.measureText(
text.substring(end, lineEnd));
}
// remove trailing spaces and newlines
while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
end -= 1;
}
final String newVal = text.substring(0, end) + ELLIPSIS;
itemContentView.setText(newVal);
hasEllipsis = true;
}
final String newVal = itemContentView.getText().subSequence(0, end) + "";
itemContentView.setText(newVal);
hasEllipsis = true;
}
linkify();
if (hasEllipsis) {
denyLinkFocus();
} else {
determineLinkFocus();
}
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
if (hasEllipsis) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
private void toggleEllipsize() {
if (itemContentView.getText().toString().equals(commentText)) {
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
ellipsize();
}
} else {
final CharSequence text = itemContentView.getText();
if (text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) {
expand();
} else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
ellipsize();
}
}
private void expand() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
itemContentView.setText(commentText);
linkify();
determineLinkFocus();
linkifyCommentContentView(v -> determineMovementMethod());
}
private void linkify() {
LinkifyCompat.addLinks(itemContentView, Linkify.WEB_URLS);
LinkifyCompat.addLinks(itemContentView, TimestampExtractor.TIMESTAMPS_PATTERN, null, null,
(match, url) -> {
try {
final var timestampMatch = TimestampExtractor
.getTimestampFromMatcher(match, commentText);
if (timestampMatch == null) {
return url;
}
return streamUrl + url.replace(Objects.requireNonNull(match.group(0)),
"#timestamp=" + timestampMatch.seconds());
} catch (final Exception ex) {
Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex);
return url;
}
});
private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) {
disposables.clear();
if (commentText != null) {
TextLinkifier.fromDescription(itemContentView, commentText,
HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
onCompletion);
}
}
}

View file

@ -0,0 +1,17 @@
package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.info_list.InfoItemBuilder;
/**
* Playlist card layout.
*/
public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder {
public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
}
}

View file

@ -0,0 +1,16 @@
package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.info_list.InfoItemBuilder;
/**
* Card layout for stream.
*/
public class StreamCardInfoItemHolder extends StreamInfoItemHolder {
public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
}
}

View file

@ -22,10 +22,11 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PignateFooterBinding;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.list.ListViewContract;
import org.schabi.newpipe.info_list.ItemViewMode;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode;
/**
* This fragment is design to be used with persistent data such as
@ -77,16 +78,23 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
super.onResume();
if (updateFlags != 0) {
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
final boolean useGrid = shouldUseGridLayout(requireContext());
itemsList.setLayoutManager(
useGrid ? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setUseGridVariant(useGrid);
itemListAdapter.notifyDataSetChanged();
refreshItemViewMode();
}
updateFlags = 0;
}
}
/**
* Updates the item view mode based on user preference.
*/
private void refreshItemViewMode() {
final ItemViewMode itemViewMode = getItemViewMode(requireContext());
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setItemViewMode(itemViewMode);
itemListAdapter.notifyDataSetChanged();
}
/*//////////////////////////////////////////////////////////////////////////
// Lifecycle - View
//////////////////////////////////////////////////////////////////////////*/
@ -120,11 +128,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
itemListAdapter = new LocalItemListAdapter(activity);
final boolean useGrid = shouldUseGridLayout(requireContext());
itemsList = rootView.findViewById(R.id.items_list);
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
refreshItemViewMode();
itemListAdapter.setUseGridVariant(useGrid);
headerRootBinding = getListHeader();
if (headerRootBinding != null) {
itemListAdapter.setHeader(headerRootBinding.getRoot());
@ -255,7 +261,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (key.equals(getString(R.string.list_view_mode_key))) {
if (getString(R.string.list_view_mode_key).equals(key)) {
updateFlags |= LIST_MODE_UPDATE_FLAG;
}
}

View file

@ -12,14 +12,19 @@ import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.holder.LocalItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
import org.schabi.newpipe.util.FallbackViewHolder;
@ -61,11 +66,17 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001;
private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002;
private final LocalItemBuilder localItemBuilder;
private final ArrayList<LocalItem> localItems;
@ -73,9 +84,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private final DateTimeFormatter dateTimeFormatter;
private boolean showFooter = false;
private boolean useGridVariant = false;
private View header = null;
private View footer = null;
private ItemViewMode itemViewMode = ItemViewMode.LIST;
public LocalItemListAdapter(final Context context) {
recordManager = new HistoryRecordManager(context);
@ -165,8 +176,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
notifyDataSetChanged();
}
public void setUseGridVariant(final boolean useGridVariant) {
this.useGridVariant = useGridVariant;
public void setItemViewMode(final ItemViewMode itemViewMode) {
this.itemViewMode = itemViewMode;
}
public void setHeader(final View header) {
@ -244,21 +255,39 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return FOOTER_TYPE;
}
final LocalItem item = localItems.get(position);
switch (item.getLocalItemType()) {
case PLAYLIST_LOCAL_ITEM:
return useGridVariant
? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return LOCAL_PLAYLIST_HOLDER_TYPE;
}
case PLAYLIST_REMOTE_ITEM:
return useGridVariant
? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return REMOTE_PLAYLIST_HOLDER_TYPE;
}
case PLAYLIST_STREAM_ITEM:
return useGridVariant
? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return STREAM_PLAYLIST_HOLDER_TYPE;
}
case STATISTIC_STREAM_ITEM:
return useGridVariant
? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
} else {
return STREAM_STATISTICS_HOLDER_TYPE;
}
default:
Log.e(TAG, "No holder type has been considered for item: ["
+ item.getLocalItemType() + "]");
@ -283,18 +312,26 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return new LocalPlaylistItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE:
return new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_HOLDER_TYPE:
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
default:
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
return new FallbackViewHolder(new View(parent.getContext()));

View file

@ -1,5 +1,6 @@
package org.schabi.newpipe.local.bookmark;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.InputType;
@ -31,6 +32,7 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import java.util.ArrayList;
import java.util.List;
import icepick.State;
@ -256,6 +258,41 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
final String rename = getString(R.string.rename);
final String delete = getString(R.string.delete);
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
final boolean isThumbnailPermanent = localPlaylistManager
.getIsPlaylistThumbnailPermanent(selectedItem.uid);
final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
final ArrayList<String> items = new ArrayList<>();
items.add(rename);
items.add(delete);
if (isThumbnailPermanent) {
items.add(unsetThumbnail);
}
final DialogInterface.OnClickListener action = (d, index) -> {
if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) {
showDeleteDialog(selectedItem.name,
localPlaylistManager.deletePlaylist(selectedItem.uid));
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
final String thumbnailUrl = localPlaylistManager
.getAutomaticPlaylistThumbnail(selectedItem.uid);
localPlaylistManager
.changePlaylistThumbnail(selectedItem.uid, thumbnailUrl, false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
};
builder.setItems(items.toArray(new String[0]), action).create().show();
}
private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
@ -269,11 +306,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
selectedItem.uid,
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete, (dialog, which) -> {
showDeleteDialog(selectedItem.name,
localPlaylistManager.deletePlaylist(selectedItem.uid));
dialog.dismiss();
})
.create()
.show();
}

View file

@ -4,6 +4,7 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
@ -13,7 +14,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.LocalItemListAdapter;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
@ -28,6 +29,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
private RecyclerView playlistRecyclerView;
private LocalItemListAdapter playlistAdapter;
private TextView playlistDuplicateIndicator;
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
@ -63,8 +65,9 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
playlistAdapter = new LocalItemListAdapter(getActivity());
playlistAdapter.setSelectedListener(selectedItem -> {
final List<StreamEntity> entities = getStreamEntities();
if (selectedItem instanceof PlaylistMetadataEntry && entities != null) {
onPlaylistSelected(playlistManager, (PlaylistMetadataEntry) selectedItem, entities);
if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) {
onPlaylistSelected(playlistManager,
(PlaylistDuplicatesEntry) selectedItem, entities);
}
});
@ -72,10 +75,13 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
playlistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
playlistRecyclerView.setAdapter(playlistAdapter);
playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate);
final View newPlaylistButton = view.findViewById(R.id.newPlaylist);
newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog());
playlistDisposables.add(playlistManager.getPlaylists()
playlistDisposables.add(playlistManager
.getPlaylistDuplicates(getStreamEntities().get(0).getUrl())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::onPlaylistsReceived));
}
@ -117,24 +123,41 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
requireDialog().dismiss();
}
private void onPlaylistsReceived(@NonNull final List<PlaylistMetadataEntry> playlists) {
if (playlistAdapter != null && playlistRecyclerView != null) {
private void onPlaylistsReceived(@NonNull final List<PlaylistDuplicatesEntry> playlists) {
if (playlistAdapter != null
&& playlistRecyclerView != null
&& playlistDuplicateIndicator != null) {
playlistAdapter.clearStreamItemList();
playlistAdapter.addItems(playlists);
playlistRecyclerView.setVisibility(View.VISIBLE);
playlistDuplicateIndicator.setVisibility(
anyPlaylistContainsDuplicates(playlists) ? View.VISIBLE : View.GONE);
}
}
private boolean anyPlaylistContainsDuplicates(final List<PlaylistDuplicatesEntry> playlists) {
return playlists.stream()
.anyMatch(playlist -> playlist.timesStreamIsContained > 0);
}
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
@NonNull final PlaylistMetadataEntry playlist,
@NonNull final PlaylistDuplicatesEntry playlist,
@NonNull final List<StreamEntity> streams) {
final Toast successToast = Toast.makeText(getContext(),
R.string.playlist_add_stream_success, Toast.LENGTH_SHORT);
final String toastText;
if (playlist.timesStreamIsContained > 0) {
toastText = getString(R.string.playlist_add_stream_success_duplicate,
playlist.timesStreamIsContained);
} else {
toastText = getString(R.string.playlist_add_stream_success);
}
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
if (playlist.thumbnailUrl
.equals("drawable://" + R.drawable.placeholder_thumbnail_playlist)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl())
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl(), false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> successToast.show()));
}

View file

@ -36,7 +36,6 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.edit
@ -69,6 +68,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
@ -80,6 +80,7 @@ import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime
@ -120,7 +121,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key.equals(getString(R.string.list_view_mode_key))) {
if (getString(R.string.list_view_mode_key).equals(key)) {
updateListViewModeOnResume = true
}
}
@ -416,11 +417,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
@SuppressLint("StringFormatMatches")
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
val itemVersion = if (shouldUseGridLayout(context)) {
StreamItem.ItemVersion.GRID
} else {
StreamItem.ItemVersion.NORMAL
val itemVersion = when (getItemViewMode(requireContext())) {
ItemViewMode.GRID -> StreamItem.ItemVersion.GRID
ItemViewMode.CARD -> StreamItem.ItemVersion.CARD
else -> StreamItem.ItemVersion.NORMAL
}
loadedState.items.forEach { it.itemVersion = itemVersion }
@ -499,7 +499,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private fun handleFeedNotAvailable(
subscriptionEntity: SubscriptionEntity,
@Nullable cause: Throwable?,
cause: Throwable?,
nextItemsErrors: List<Throwable>
) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())

View file

@ -42,12 +42,13 @@ data class StreamItem(
override fun getId(): Long = stream.uid
enum class ItemVersion { NORMAL, MINI, GRID }
enum class ItemVersion { NORMAL, MINI, GRID, CARD }
override fun getLayout(): Int = when (itemVersion) {
ItemVersion.NORMAL -> R.layout.list_stream_item
ItemVersion.MINI -> R.layout.list_stream_mini_item
ItemVersion.GRID -> R.layout.list_stream_grid_item
ItemVersion.CARD -> R.layout.list_stream_card_item
}
override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view)

View file

@ -0,0 +1,17 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
/**
* Playlist card layout.
*/
public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder {
public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
}
}

View file

@ -4,6 +4,7 @@ import android.view.View;
import android.view.ViewGroup;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
@ -13,6 +14,9 @@ import org.schabi.newpipe.util.Localization;
import java.time.format.DateTimeFormatter;
public class LocalPlaylistItemHolder extends PlaylistItemHolder {
private static final float GRAYED_OUT_ALPHA = 0.6f;
public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, parent);
}
@ -38,6 +42,13 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder {
PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView);
if (item instanceof PlaylistDuplicatesEntry
&& ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) {
itemView.setAlpha(GRAYED_OUT_ALPHA);
} else {
itemView.setAlpha(1.0f);
}
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
}
}

View file

@ -0,0 +1,17 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
/**
* Local playlist stream UI. This also includes a handle to rearrange the videos.
*/
public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder {
public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent);
}
}

View file

@ -0,0 +1,13 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder {
public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
}
}

View file

@ -0,0 +1,17 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
/**
* Playlist card UI for list item.
*/
public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder {
public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
}
}

View file

@ -408,7 +408,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.firstElement()
.zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> {
// Remove Watched, Functionality data
final List<PlaylistStreamEntry> notWatchedItems = new ArrayList<>();
final List<PlaylistStreamEntry> itemsToKeep = new ArrayList<>();
final boolean isThumbnailPermanent = playlistManager
.getIsPlaylistThumbnailPermanent(playlistId);
boolean thumbnailVideoRemoved = false;
if (removePartiallyWatched) {
@ -417,8 +419,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
playlistItem.getStreamId());
if (indexInHistory < 0) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
thumbnailVideoRemoved = true;
@ -438,8 +440,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
if (indexInHistory < 0 || (streamStateEntity != null
&& !streamStateEntity.isFinished(duration))) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
thumbnailVideoRemoved = true;
@ -447,17 +449,17 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
}
return new Pair<>(notWatchedItems, thumbnailVideoRemoved);
return new Pair<>(itemsToKeep, thumbnailVideoRemoved);
});
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(flow -> {
final List<PlaylistStreamEntry> notWatchedItems = flow.first;
final List<PlaylistStreamEntry> itemsToKeep = flow.first;
final boolean thumbnailVideoRemoved = flow.second;
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(notWatchedItems);
itemListAdapter.addItems(itemsToKeep);
saveChanges();
if (thumbnailVideoRemoved) {
@ -589,8 +591,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
disposables.add(disposable);
}
private void changeThumbnailUrl(final String thumbnailUrl) {
if (playlistManager == null) {
private void changeThumbnailUrl(final String thumbnailUrl, final boolean isPermanent) {
if (playlistManager == null || (!isPermanent && playlistManager
.getIsPlaylistThumbnailPermanent(playlistId))) {
return;
}
@ -604,7 +607,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
final Disposable disposable = playlistManager
.changePlaylistThumbnail(playlistId, thumbnailUrl)
.changePlaylistThumbnail(playlistId, thumbnailUrl, isPermanent)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show(), throwable ->
showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
@ -613,6 +616,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
private void updateThumbnailUrl() {
if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) {
return;
}
final String newThumbnailUrl;
if (!itemListAdapter.getItemsList().isEmpty()) {
@ -622,7 +629,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
newThumbnailUrl = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
}
changeThumbnailUrl(newThumbnailUrl);
changeThumbnailUrl(newThumbnailUrl, false);
}
private void openRemoveDuplicatesDialog() {
@ -828,7 +835,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.setAction(
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
(f, i) ->
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl(),
true))
.setAction(
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteItem(item))

View file

@ -2,7 +2,9 @@ package org.schabi.newpipe.local.playlist;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO;
@ -41,7 +43,7 @@ public class LocalPlaylistManager {
}
final StreamEntity defaultStream = streams.get(0);
final PlaylistEntity newPlaylist =
new PlaylistEntity(name, defaultStream.getThumbnailUrl());
new PlaylistEntity(name, defaultStream.getThumbnailUrl(), false);
return Maybe.fromCallable(() -> database.runInTransaction(() ->
upsertStreams(playlistTable.insert(newPlaylist), streams, 0))
@ -91,6 +93,18 @@ public class LocalPlaylistManager {
.getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io());
}
/**
* Get playlists with attached information about how many times the provided stream is already
* contained in each playlist.
*
* @param streamUrl the stream url for which to check for duplicates
* @return a list of {@link PlaylistDuplicatesEntry}
*/
public Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicates(final String streamUrl) {
return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl)
.subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistStreamEntry>> getPlaylistStreams(final long playlistId) {
return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
}
@ -101,21 +115,33 @@ public class LocalPlaylistManager {
}
public Maybe<Integer> renamePlaylist(final long playlistId, final String name) {
return modifyPlaylist(playlistId, name, null);
return modifyPlaylist(playlistId, name, null, false);
}
public Maybe<Integer> changePlaylistThumbnail(final long playlistId,
final String thumbnailUrl) {
return modifyPlaylist(playlistId, null, thumbnailUrl);
final String thumbnailUrl,
final boolean isPermanent) {
return modifyPlaylist(playlistId, null, thumbnailUrl, isPermanent);
}
public String getPlaylistThumbnail(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl();
}
public boolean getIsPlaylistThumbnailPermanent(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0)
.getIsThumbnailPermanent();
}
public String getAutomaticPlaylistThumbnail(final long playlistId) {
final String def = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
return playlistStreamTable.getAutomaticThumbnailUrl(playlistId, def).blockingFirst();
}
private Maybe<Integer> modifyPlaylist(final long playlistId,
@Nullable final String name,
@Nullable final String thumbnailUrl) {
@Nullable final String thumbnailUrl,
final boolean isPermanent) {
return playlistTable.getPlaylist(playlistId)
.firstElement()
.filter(playlistEntities -> !playlistEntities.isEmpty())
@ -126,6 +152,7 @@ public class LocalPlaylistManager {
}
if (thumbnailUrl != null) {
playlist.setThumbnailUrl(thumbnailUrl);
playlist.setIsThumbnailPermanent(isPermanent);
}
return playlistTable.update(playlist);
}).subscribeOn(Schedulers.io());

View file

@ -51,7 +51,8 @@ enum class FeedGroupIcon(
WORLD(34, R.drawable.ic_public),
STAR(35, R.drawable.ic_stars),
SUN(36, R.drawable.ic_wb_sunny),
RSS(37, R.drawable.ic_rss_feed);
RSS(37, R.drawable.ic_rss_feed),
WHATS_NEW(38, R.drawable.ic_subscriptions);
@DrawableRes
fun getDrawableRes(): Int {

View file

@ -433,10 +433,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
clear()
if (listViewMode) {
add(FeedGroupAddNewItem())
add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS))
add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
} else {
add(FeedGroupAddNewGridItem())
add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS))
add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
}
addAll(groups)
}

View file

@ -143,11 +143,9 @@ public final class PlayQueueActivity extends AppCompatActivity
NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true);
return true;
case R.id.action_switch_popup:
if (PermissionHelper.isPopupEnabled(this)) {
if (PermissionHelper.isPopupEnabledElseAsk(this)) {
this.player.setRecovery();
NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true);
} else {
PermissionHelper.showPopupEnablementToast(this);
}
return true;
case R.id.action_switch_background:

View file

@ -348,7 +348,7 @@ public final class Player implements PlaybackListener, Listener {
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
final boolean samePlayQueue = playQueue != null && playQueue.equals(newQueue);
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
@ -1695,26 +1695,25 @@ public final class Player implements PlaybackListener, Listener {
}
private void saveStreamProgressState(final long progressMillis) {
//noinspection SimplifyOptionalCallChains
if (!getCurrentStreamInfo().isPresent()
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
return;
}
if (DEBUG) {
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
+ ", currentMetadata=[" + getCurrentStreamInfo().get().getName() + "]");
}
getCurrentStreamInfo().ifPresent(info -> {
if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
return;
}
if (DEBUG) {
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
+ ", currentMetadata=[" + info.getName() + "]");
}
databaseUpdateDisposable
.add(recordManager.saveStreamState(getCurrentStreamInfo().get(), progressMillis)
.observeOn(AndroidSchedulers.mainThread())
.doOnError(e -> {
if (DEBUG) {
e.printStackTrace();
}
})
.onErrorComplete()
.subscribe());
databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis)
.observeOn(AndroidSchedulers.mainThread())
.doOnError(e -> {
if (DEBUG) {
e.printStackTrace();
}
})
.onErrorComplete()
.subscribe());
});
}
public void saveStreamProgressState() {
@ -1876,23 +1875,16 @@ public final class Player implements PlaybackListener, Listener {
loadController.disablePreloadingOfCurrentTrack();
}
@Nullable
public VideoStream getSelectedVideoStream() {
@Nullable final MediaItemTag.Quality quality = Optional.ofNullable(currentMetadata)
public Optional<VideoStream> getSelectedVideoStream() {
return Optional.ofNullable(currentMetadata)
.flatMap(MediaItemTag::getMaybeQuality)
.orElse(null);
if (quality == null) {
return null;
}
final List<VideoStream> availableStreams = quality.getSortedVideoStreams();
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
if (selectedStreamIndex >= 0 && availableStreams.size() > selectedStreamIndex) {
return availableStreams.get(selectedStreamIndex);
} else {
return null;
}
.filter(quality -> {
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
return selectedStreamIndex >= 0
&& selectedStreamIndex < quality.getSortedVideoStreams().size();
})
.map(quality -> quality.getSortedVideoStreams()
.get(quality.getSelectedVideoStreamIndex()));
}
//endregion
@ -2036,40 +2028,36 @@ public final class Player implements PlaybackListener, Listener {
// in livestreams) so we will be not able to execute the block below.
// Reload the play queue manager in this case, which is the behavior when we don't know the
// index of the video renderer or playQueueManagerReloadingNeeded returns true.
final Optional<StreamInfo> optCurrentStreamInfo = getCurrentStreamInfo();
if (!optCurrentStreamInfo.isPresent()) {
reloadPlayQueueManager();
setRecovery();
return;
}
getCurrentStreamInfo().ifPresentOrElse(info -> {
// In the case we don't know the source type, fallback to the one with video with audio
// or audio-only source.
final SourceType sourceType = videoResolver.getStreamSourceType()
.orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
final StreamInfo info = optCurrentStreamInfo.get();
if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
reloadPlayQueueManager();
} else {
if (StreamTypeUtil.isAudio(info.getStreamType())) {
// Nothing to do more than setting the recovery position
setRecovery();
return;
}
// In the case we don't know the source type, fallback to the one with video with audio or
// audio-only source.
final SourceType sourceType = videoResolver.getStreamSourceType().orElse(
SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
final var parametersBuilder = trackSelector.buildUponParameters();
if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
reloadPlayQueueManager();
} else {
if (StreamTypeUtil.isAudio(info.getStreamType())) {
// Nothing to do more than setting the recovery position
setRecovery();
return;
// Enable/disable the video track and the ability to select subtitles
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled);
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled);
trackSelector.setParameters(parametersBuilder);
}
final DefaultTrackSelector.Parameters.Builder parametersBuilder =
trackSelector.buildUponParameters();
// Enable/disable the video track and the ability to select subtitles
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled);
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled);
trackSelector.setParameters(parametersBuilder);
}
setRecovery();
setRecovery();
}, () -> {
// This is executed when the current stream info is not available.
reloadPlayQueueManager();
setRecovery();
});
}
/**

View file

@ -6,6 +6,7 @@ import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.core.os.postDelayed
import org.schabi.newpipe.databinding.PlayerBinding
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.ui.VideoPlayerUi
@ -132,13 +133,6 @@ abstract class BasePlayerGestureListener(
private var doubleTapDelay = DOUBLE_TAP_DELAY
private val doubleTapHandler: Handler = Handler(Looper.getMainLooper())
private val doubleTapRunnable = Runnable {
if (DEBUG)
Log.d(TAG, "doubleTapRunnable called")
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
private fun startMultiDoubleTap(e: MotionEvent) {
if (!isDoubleTapping) {
@ -155,8 +149,15 @@ abstract class BasePlayerGestureListener(
Log.d(TAG, "keepInDoubleTapMode called")
isDoubleTapping = true
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay)
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)
doubleTapHandler.postDelayed(DOUBLE_TAP_DELAY, DOUBLE_TAP) {
if (DEBUG) {
Log.d(TAG, "doubleTapRunnable called")
}
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
}
fun endMultiDoubleTap() {
@ -164,7 +165,7 @@ abstract class BasePlayerGestureListener(
Log.d(TAG, "endMultiDoubleTap called")
isDoubleTapping = false
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)
doubleTapControls?.onDoubleTapFinished()
}
@ -181,6 +182,7 @@ abstract class BasePlayerGestureListener(
private const val TAG = "BasePlayerGestListener"
private val DEBUG = Player.DEBUG
private const val DOUBLE_TAP = "doubleTap"
private const val DOUBLE_TAP_DELAY = 550L
}
}

View file

@ -14,6 +14,7 @@ import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.provider.Settings;
import android.view.accessibility.CaptioningManager;
@ -382,8 +383,11 @@ public final class PlayerHelper {
public static boolean globalScreenOrientationLocked(final Context context) {
// 1: Screen orientation changes using accelerometer
// 0: Screen orientation is locked
// if the accelerometer sensor is missing completely, assume locked orientation
return android.provider.Settings.System.getInt(
context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0;
context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0
|| !context.getPackageManager()
.hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER);
}
public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) {

View file

@ -61,12 +61,11 @@ public interface MediaItemTag {
@NonNull
static Optional<MediaItemTag> from(@Nullable final MediaItem mediaItem) {
if (mediaItem == null || mediaItem.localConfiguration == null
|| !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) {
return Optional.empty();
}
return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag);
return Optional.ofNullable(mediaItem)
.map(item -> item.localConfiguration)
.map(localConfiguration -> localConfiguration.tag)
.filter(MediaItemTag.class::isInstance)
.map(MediaItemTag.class::cast);
}
@NonNull

View file

@ -1,21 +1,27 @@
package org.schabi.newpipe.player.mediasource;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.CompositeMediaSource;
import com.google.android.exoplayer2.source.MediaPeriod;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.WrappingMediaSource;
import com.google.android.exoplayer2.upstream.Allocator;
import com.google.android.exoplayer2.upstream.TransferListener;
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
public class LoadedMediaSource extends WrappingMediaSource implements ManagedMediaSource {
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class LoadedMediaSource extends CompositeMediaSource<Integer> implements ManagedMediaSource {
private final MediaSource source;
private final PlayQueueItem stream;
private final MediaItem mediaItem;
private final long expireTimestamp;
/**
* Uses a {@link WrappingMediaSource} to wrap one child {@link MediaSource}
* Uses a {@link CompositeMediaSource} to wrap one or more child {@link MediaSource}s
* containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration
* timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under
* {@link ManagedMediaSourcePlaylist}.
@ -30,7 +36,7 @@ public class LoadedMediaSource extends WrappingMediaSource implements ManagedMed
@NonNull final MediaItemTag tag,
@NonNull final PlayQueueItem stream,
final long expireTimestamp) {
super(source);
this.source = source;
this.stream = stream;
this.expireTimestamp = expireTimestamp;
@ -45,6 +51,51 @@ public class LoadedMediaSource extends WrappingMediaSource implements ManagedMed
return System.currentTimeMillis() >= expireTimestamp;
}
/**
* Delegates the preparation of child {@link MediaSource}s to the
* {@link CompositeMediaSource} wrapper. Since all {@link LoadedMediaSource}s use only
* a single child media, the child id of 0 is always used (sonar doesn't like null as id here).
*
* @param mediaTransferListener A data transfer listener that will be registered by the
* {@link CompositeMediaSource} for child source preparation.
*/
@Override
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener);
prepareChildSource(0, source);
}
/**
* When any child {@link MediaSource} is prepared, the refreshed {@link Timeline} can
* be listened to here. But since {@link LoadedMediaSource} has only a single child source,
* this method is called only once until {@link #releaseSourceInternal()} is called.
* <br><br>
* On refresh, the {@link CompositeMediaSource} delegate will be notified with the
* new {@link Timeline}, otherwise {@link #createPeriod(MediaPeriodId, Allocator, long)}
* will not be called and playback may be stalled.
*
* @param id The unique id used to prepare the child source.
* @param mediaSource The child source whose source info has been refreshed.
* @param timeline The new timeline of the child source.
*/
@Override
protected void onChildSourceInfoRefreshed(final Integer id,
final MediaSource mediaSource,
final Timeline timeline) {
refreshSourceInfo(timeline);
}
@Override
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
final long startPositionUs) {
return source.createPeriod(id, allocator, startPositionUs);
}
@Override
public void releasePeriod(final MediaPeriod mediaPeriod) {
source.releasePeriod(mediaPeriod);
}
@NonNull
@Override
public MediaItem getMediaItem() {

View file

@ -7,8 +7,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.collection.ArraySet;
import com.google.android.exoplayer2.source.MediaSource;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
@ -23,10 +21,10 @@ import org.schabi.newpipe.player.playqueue.events.MoveEvent;
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
import org.schabi.newpipe.util.ServiceHelper;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@ -43,6 +41,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException;
import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG;
import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis;
public class MediaSourceManager {
@NonNull
@ -421,31 +420,39 @@ public class MediaSourceManager {
}
private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
return stream.getStream().map(streamInfo -> {
final MediaSource source = playbackListener.sourceOf(stream, streamInfo);
if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) {
final String message = "Unable to resolve source from stream info. "
+ "URL: " + stream.getUrl() + ", "
+ "audio count: " + streamInfo.getAudioStreams().size() + ", "
+ "video count: " + streamInfo.getVideoOnlyStreams().size() + ", "
+ streamInfo.getVideoStreams().size();
return (ManagedMediaSource)
FailedMediaSource.of(stream, new MediaSourceResolutionException(message));
}
final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get();
final long expiration = System.currentTimeMillis()
+ ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId());
return new LoadedMediaSource(source, tag, stream, expiration);
}).onErrorReturn(throwable -> {
if (throwable instanceof ExtractionException) {
return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
}
// Non-source related error expected here (e.g. network),
// should allow retry shortly after the error.
return FailedMediaSource.of(stream, new Exception(throwable),
/*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS));
});
return stream.getStream()
.map(streamInfo -> Optional
.ofNullable(playbackListener.sourceOf(stream, streamInfo))
.<ManagedMediaSource>flatMap(source ->
MediaItemTag.from(source.getMediaItem())
.map(tag -> {
final int serviceId = streamInfo.getServiceId();
final long expiration = System.currentTimeMillis()
+ getCacheExpirationMillis(serviceId);
return new LoadedMediaSource(source, tag, stream,
expiration);
})
)
.orElseGet(() -> {
final String message = "Unable to resolve source from stream info. "
+ "URL: " + stream.getUrl()
+ ", audio count: " + streamInfo.getAudioStreams().size()
+ ", video count: " + streamInfo.getVideoOnlyStreams().size()
+ ", " + streamInfo.getVideoStreams().size();
return FailedMediaSource.of(stream,
new MediaSourceResolutionException(message));
})
)
.onErrorReturn(throwable -> {
if (throwable instanceof ExtractionException) {
return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
}
// Non-source related error expected here (e.g. network),
// should allow retry shortly after the error.
final long allowRetryIn = TimeUnit.MILLISECONDS.convert(3,
TimeUnit.SECONDS);
return FailedMediaSource.of(stream, new Exception(throwable), allowRetryIn);
});
}
private void onMediaSourceReceived(@NonNull final PlayQueueItem item,

View file

@ -518,12 +518,10 @@ public abstract class PlayQueue implements Serializable {
* This method also gives a chance to track history of items in a queue in
* VideoDetailFragment without duplicating items from two identical queues
*/
@Override
public boolean equals(@Nullable final Object obj) {
if (!(obj instanceof PlayQueue)) {
public boolean equalStreams(@Nullable final PlayQueue other) {
if (other == null) {
return false;
}
final PlayQueue other = (PlayQueue) obj;
if (size() != other.size()) {
return false;
}
@ -539,9 +537,11 @@ public abstract class PlayQueue implements Serializable {
return true;
}
@Override
public int hashCode() {
return streams.hashCode();
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
if (equalStreams(other)) {
return other.getIndex() == getIndex();
}
return false;
}
public boolean isDisposed() {

View file

@ -8,6 +8,7 @@ import android.widget.ImageView;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.BitmapCompat;
import androidx.core.math.MathUtils;
import androidx.preference.PreferenceManager;
@ -16,7 +17,6 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.util.DeviceUtils;
import java.lang.annotation.Retention;
import java.util.Optional;
import java.util.function.IntSupplier;
import static java.lang.annotation.RetentionPolicy.SOURCE;
@ -66,21 +66,19 @@ public final class SeekbarPreviewThumbnailHelper {
public static void tryResizeAndSetSeekbarPreviewThumbnail(
@NonNull final Context context,
@NonNull final Optional<Bitmap> optPreviewThumbnail,
@Nullable final Bitmap previewThumbnail,
@NonNull final ImageView currentSeekbarPreviewThumbnail,
@NonNull final IntSupplier baseViewWidthSupplier) {
if (!optPreviewThumbnail.isPresent()) {
if (previewThumbnail == null) {
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
return;
}
currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE);
final Bitmap srcBitmap = optPreviewThumbnail.get();
// Resize original bitmap
try {
final int srcWidth = srcBitmap.getWidth() > 0 ? srcBitmap.getWidth() : 1;
final int srcWidth = previewThumbnail.getWidth() > 0 ? previewThumbnail.getWidth() : 1;
final int newWidth = MathUtils.clamp(
// Use 1/4 of the width for the preview
Math.round(baseViewWidthSupplier.getAsInt() / 4f),
@ -90,15 +88,15 @@ public final class SeekbarPreviewThumbnailHelper {
Math.round(srcWidth * 2.5f));
final float scaleFactor = (float) newWidth / srcWidth;
final int newHeight = (int) (srcBitmap.getHeight() * scaleFactor);
final int newHeight = (int) (previewThumbnail.getHeight() * scaleFactor);
currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat.createScaledBitmap(srcBitmap,
newWidth, newHeight, null, true));
currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat
.createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true));
} catch (final Exception ex) {
Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex);
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
} finally {
srcBitmap.recycle();
previewThumbnail.recycle();
}
}
}

View file

@ -32,7 +32,6 @@ import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
@ -40,6 +39,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
@ -74,6 +75,7 @@ import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@ -451,11 +453,9 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
getParentActivity().map(Activity::getWindow).ifPresent(window -> {
window.setStatusBarColor(Color.TRANSPARENT);
window.setNavigationBarColor(Color.TRANSPARENT);
final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
window.getDecorView().setSystemUiVisibility(visibility);
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
WindowCompat.setDecorFitsSystemWindows(window, false);
WindowCompat.getInsetsController(window, window.getDecorView())
.show(WindowInsetsCompat.Type.systemBars());
});
}
}
@ -746,15 +746,10 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
}
private int getNearestStreamSegmentPosition(final long playbackPosition) {
//noinspection SimplifyOptionalCallChains
if (!player.getCurrentStreamInfo().isPresent()) {
return 0;
}
int nearestPosition = 0;
final List<StreamSegment> segments = player.getCurrentStreamInfo()
.get()
.getStreamSegments();
.map(StreamInfo::getStreamSegments)
.orElse(Collections.emptyList());
for (int i = 0; i < segments.size(); i++) {
if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) {
@ -866,14 +861,11 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
@Override
protected void onPlaybackSpeedClicked() {
final AppCompatActivity activity = getParentActivity().orElse(null);
if (activity == null) {
return;
}
PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(),
player.getPlaybackSkipSilence(), player::setPlaybackParameters)
.show(activity.getSupportFragmentManager(), null);
getParentActivity().ifPresent(activity ->
PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(),
player.getPlaybackPitch(), player.getPlaybackSkipSilence(),
player::setPlaybackParameters)
.show(activity.getSupportFragmentManager(), null));
}
@Override
@ -973,22 +965,22 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
//////////////////////////////////////////////////////////////////////////*/
//region Getters
private Optional<Context> getParentContext() {
return Optional.ofNullable(binding.getRoot().getParent())
.filter(ViewGroup.class::isInstance)
.map(parent -> ((ViewGroup) parent).getContext());
}
public Optional<AppCompatActivity> getParentActivity() {
final ViewParent rootParent = binding.getRoot().getParent();
if (rootParent instanceof ViewGroup) {
final Context activity = ((ViewGroup) rootParent).getContext();
if (activity instanceof AppCompatActivity) {
return Optional.of((AppCompatActivity) activity);
}
}
return Optional.empty();
return getParentContext()
.filter(AppCompatActivity.class::isInstance)
.map(AppCompatActivity.class::cast);
}
public boolean isLandscape() {
// DisplayMetrics from activity context knows about MultiWindow feature
// while DisplayMetrics from app context doesn't
return DeviceUtils.isLandscape(
getParentActivity().map(Context.class::cast).orElse(player.getService()));
return DeviceUtils.isLandscape(getParentContext().orElse(player.getService()));
}
//endregion
}

View file

@ -566,7 +566,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
SeekbarPreviewThumbnailHelper
.tryResizeAndSetSeekbarPreviewThumbnail(
player.getContext(),
seekbarPreviewThumbnailHolder.getBitmapAt(progress),
seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null),
binding.currentSeekbarPreviewThumbnail,
binding.subtitleView::getWidth);
@ -982,61 +982,56 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
}
private void updateStreamRelatedViews() {
//noinspection SimplifyOptionalCallChains
if (!player.getCurrentStreamInfo().isPresent()) {
return;
}
final StreamInfo info = player.getCurrentStreamInfo().get();
player.getCurrentStreamInfo().ifPresent(info -> {
binding.qualityTextView.setVisibility(View.GONE);
binding.playbackSpeed.setVisibility(View.GONE);
binding.qualityTextView.setVisibility(View.GONE);
binding.playbackSpeed.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.GONE);
switch (info.getStreamType()) {
case AUDIO_STREAM:
case POST_LIVE_AUDIO_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
case AUDIO_LIVE_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
case LIVE_STREAM:
binding.surfaceView.setVisibility(View.VISIBLE);
binding.endScreen.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
case VIDEO_STREAM:
case POST_LIVE_STREAM:
//noinspection SimplifyOptionalCallChains
if (player.getCurrentMetadata() != null
&& !player.getCurrentMetadata().getMaybeQuality().isPresent()
|| (info.getVideoStreams().isEmpty()
&& info.getVideoOnlyStreams().isEmpty())) {
switch (info.getStreamType()) {
case AUDIO_STREAM:
case POST_LIVE_AUDIO_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
}
buildQualityMenu();
case AUDIO_LIVE_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
binding.qualityTextView.setVisibility(View.VISIBLE);
binding.surfaceView.setVisibility(View.VISIBLE);
// fallthrough
default:
binding.endScreen.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
}
case LIVE_STREAM:
binding.surfaceView.setVisibility(View.VISIBLE);
binding.endScreen.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
buildPlaybackSpeedMenu();
binding.playbackSpeed.setVisibility(View.VISIBLE);
case VIDEO_STREAM:
case POST_LIVE_STREAM:
if (player.getCurrentMetadata() != null
&& player.getCurrentMetadata().getMaybeQuality().isEmpty()
|| (info.getVideoStreams().isEmpty()
&& info.getVideoOnlyStreams().isEmpty())) {
break;
}
buildQualityMenu();
binding.qualityTextView.setVisibility(View.VISIBLE);
binding.surfaceView.setVisibility(View.VISIBLE);
// fallthrough
default:
binding.endScreen.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
}
buildPlaybackSpeedMenu();
binding.playbackSpeed.setVisibility(View.VISIBLE);
});
}
//endregion
@ -1065,12 +1060,11 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat
.getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution());
}
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
if (selectedVideoStream != null) {
binding.qualityTextView.setText(selectedVideoStream.getResolution());
}
qualityPopupMenu.setOnMenuItemClickListener(this);
qualityPopupMenu.setOnDismissListener(this);
player.getSelectedVideoStream()
.ifPresent(s -> binding.qualityTextView.setText(s.getResolution()));
}
private void buildPlaybackSpeedMenu() {
@ -1176,12 +1170,9 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
qualityPopupMenu.show();
isSomePopupMenuVisible = true;
final VideoStream videoStream = player.getSelectedVideoStream();
if (videoStream != null) {
//noinspection SetTextI18n
binding.qualityTextView.setText(MediaFormat.getNameById(videoStream.getFormatId())
+ " " + videoStream.getResolution());
}
player.getSelectedVideoStream()
.map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution())
.ifPresent(binding.qualityTextView::setText);
}
/**
@ -1198,8 +1189,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) {
final int menuItemIndex = menuItem.getItemId();
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
//noinspection SimplifyOptionalCallChains
if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent()) {
if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) {
return true;
}
@ -1238,10 +1228,9 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]");
}
isSomePopupMenuVisible = false; //TODO check if this works
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
if (selectedVideoStream != null) {
binding.qualityTextView.setText(selectedVideoStream.getResolution());
}
player.getSelectedVideoStream()
.ifPresent(s -> binding.qualityTextView.setText(s.getResolution()));
if (player.isPlaying()) {
hideControls(DEFAULT_CONTROLS_DURATION, 0);
hideSystemUIIfNeeded();
@ -1300,9 +1289,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
// Build UI
buildCaptionMenu(availableLanguages);
//noinspection SimplifyOptionalCallChains
if (player.getTrackSelector().getParameters().getRendererDisabled(
player.getCaptionRendererIndex()) || !selectedTracks.isPresent()) {
player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) {
binding.captionTextView.setText(R.string.caption_none);
} else {
binding.captionTextView.setText(selectedTracks.get().language);

View file

@ -27,14 +27,14 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
updateSeekOptions();
listener = (sharedPreferences, s) -> {
listener = (sharedPreferences, key) -> {
// on M and above, if user chooses to minimise to popup player on exit
// and the app doesn't have display over other apps permission,
// show a snackbar to let the user give permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& s.equals(getString(R.string.minimize_on_exit_key))) {
final String newSetting = sharedPreferences.getString(s, null);
&& getString(R.string.minimize_on_exit_key).equals(key)) {
final String newSetting = sharedPreferences.getString(key, null);
if (newSetting != null
&& newSetting.equals(getString(R.string.minimize_on_exit_popup_key))
&& !Settings.canDrawOverlays(getContext())) {
@ -46,7 +46,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
.show();
}
} else if (s.equals(getString(R.string.use_inexact_seek_key))) {
} else if (getString(R.string.use_inexact_seek_key).equals(key)) {
updateSeekOptions();
}
};

View file

@ -248,7 +248,7 @@ public abstract class Tab {
@DrawableRes
@Override
public int getTabIconRes(final Context context) {
return R.drawable.ic_rss_feed;
return R.drawable.ic_subscriptions;
}
@Override

View file

@ -20,6 +20,7 @@ public final class TabsJsonHelper {
private static final List<Tab> FALLBACK_INITIAL_TABS_LIST = List.of(
Tab.Type.DEFAULT_KIOSK.getTab(),
Tab.Type.FEED.getTab(),
Tab.Type.SUBSCRIPTIONS.getTab(),
Tab.Type.BOOKMARKS.getTab());

View file

@ -73,7 +73,7 @@ public final class TabsManager {
private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() {
return (sp, key) -> {
if (key.equals(savedTabsKey)) {
if (savedTabsKey.equals(key)) {
if (savedTabsChangeListener != null) {
savedTabsChangeListener.onTabsChanged();
}

View file

@ -1,71 +0,0 @@
package org.schabi.newpipe.util;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.InternalUrlsHandler;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
@Override
public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) {
return false;
}
final TextView widget = (TextView) v;
final Object text = widget.getText();
if (text instanceof Spanned) {
final Spannable buffer = (Spannable) text;
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP
|| action == MotionEvent.ACTION_DOWN) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= widget.getTotalPaddingLeft();
y -= widget.getTotalPaddingTop();
x += widget.getScrollX();
y += widget.getScrollY();
final Layout layout = widget.getLayout();
final int line = layout.getLineForVertical(y);
final int off = layout.getOffsetForHorizontal(line, x);
final ClickableSpan[] link = buffer.getSpans(off, off,
ClickableSpan.class);
if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
if (link[0] instanceof URLSpan) {
final String url = ((URLSpan) link[0]).getURL();
if (!InternalUrlsHandler.handleUrlCommentsTimestamp(
new CompositeDisposable(), v.getContext(), url)) {
ShareUtils.openUrlInBrowser(v.getContext(), url, false);
}
}
} else if (action == MotionEvent.ACTION_DOWN) {
Selection.setSelection(buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
}
return true;
}
}
}
return false;
}
}

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
import static android.content.Context.INPUT_SERVICE;
import android.annotation.SuppressLint;
import android.app.UiModeManager;
import android.content.Context;
@ -27,11 +29,10 @@ import org.schabi.newpipe.R;
import java.lang.reflect.Method;
import static android.content.Context.INPUT_SERVICE;
public final class DeviceUtils {
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung");
private static Boolean isTV = null;
private static Boolean isFireTV = null;
@ -120,6 +121,10 @@ public final class DeviceUtils {
return true;
}
if (!SAMSUNG) {
return false;
// DeX is Samsung-specific, skip the checks below on non-Samsung devices
}
// DeX check for standalone and multi-window mode, from:
// https://developer.samsung.com/samsung-dex/modify-optimizing.html
try {

View file

@ -20,6 +20,7 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.content.Context;
import android.util.Log;
@ -51,7 +52,7 @@ import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
import org.schabi.newpipe.util.external_communication.TextLinkifier;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.Collections;
import java.util.List;
@ -319,8 +320,9 @@ public final class ExtractorHelper {
}
metaInfoSeparator.setVisibility(View.VISIBLE);
TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(),
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables);
TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(),
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables,
SET_LINK_MOVEMENT_METHOD);
}
}

View file

@ -156,8 +156,7 @@ public final class NavigationHelper {
public static void playOnPopupPlayer(final Context context,
final PlayQueue queue,
final boolean resumePlayback) {
if (!PermissionHelper.isPopupEnabled(context)) {
PermissionHelper.showPopupEnablementToast(context);
if (!PermissionHelper.isPopupEnabledElseAsk(context)) {
return;
}
@ -183,6 +182,10 @@ public final class NavigationHelper {
public static void enqueueOnPlayer(final Context context,
final PlayQueue queue,
final PlayerType playerType) {
if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) {
return;
}
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);

View file

@ -9,8 +9,6 @@ import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.view.Gravity;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.RequiresApi;
@ -128,18 +126,21 @@ public final class PermissionHelper {
}
}
public static boolean isPopupEnabled(final Context context) {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| checkSystemAlertWindowPermission(context);
}
public static void showPopupEnablementToast(final Context context) {
final Toast toast =
Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG);
final TextView messageView = toast.getView().findViewById(android.R.id.message);
if (messageView != null) {
messageView.setGravity(Gravity.CENTER);
/**
* Determines whether the popup is enabled, and if it is not, starts the system activity to
* request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a
* toast to the user explaining why the permission is needed.
*
* @param context the Android context
* @return whether the popup is enabled
*/
public static boolean isPopupEnabledElseAsk(final Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| checkSystemAlertWindowPermission(context)) {
return true;
} else {
Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show();
return false;
}
toast.show();
}
}

View file

@ -41,6 +41,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.info_list.ItemViewMode;
public final class ThemeHelper {
private ThemeHelper() {
@ -332,7 +333,6 @@ public final class ThemeHelper {
}
}
/**
* Returns whether the grid layout or the list layout should be used. If the user set "auto"
* mode in settings, decides based on screen orientation (landscape) and size.
@ -341,19 +341,8 @@ public final class ThemeHelper {
* @return true:use grid layout, false:use list layout
*/
public static boolean shouldUseGridLayout(final Context context) {
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value));
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
return false;
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
return true;
} else /* listMode.equals("auto") */ {
final Configuration configuration = context.getResources().getConfiguration();
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
}
final ItemViewMode mode = getItemViewMode(context);
return mode == ItemViewMode.GRID;
}
/**
@ -367,6 +356,36 @@ public final class ThemeHelper {
context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width));
}
/**
* Returns item view mode.
* @param context to read preference and parse string
* @return Returns one of ItemViewMode
*/
public static ItemViewMode getItemViewMode(final Context context) {
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value));
final ItemViewMode result;
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
result = ItemViewMode.LIST;
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
result = ItemViewMode.GRID;
} else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) {
result = ItemViewMode.CARD;
} else {
// Auto mode - evaluate whether to use Grid based on screen real estate.
final Configuration configuration = context.getResources().getConfiguration();
final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
if (useGrid) {
result = ItemViewMode.GRID;
} else {
result = ItemViewMode.LIST;
}
}
return result;
}
/**
* Calculates the number of grid stream info items that can fit horizontally on the screen. The
* width of a grid stream info item is obtained from the thumbnail width plus the right and left

View file

@ -90,19 +90,16 @@ public final class ShareUtils {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, true);
} else {
if (defaultPackageName.isEmpty()) {
// No app installed to open a web url
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show();
return false;
} else {
try {
try {
// will be empty on Android 12+
if (!defaultPackageName.isEmpty()) {
intent.setPackage(defaultPackageName);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
}
@ -313,10 +310,15 @@ public final class ShareUtils {
return;
}
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
if (Build.VERSION.SDK_INT < 33) {
// Android 13 has its own "copied to clipboard" dialog
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
try {
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
if (Build.VERSION.SDK_INT < 33) {
// Android 13 has its own "copied to clipboard" dialog
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
}
} catch (final Exception e) {
Log.e(TAG, "Error when trying to copy text to clipboard", e);
Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show();
}
}

View file

@ -1,289 +0,0 @@
package org.schabi.newpipe.util.external_communication;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.noties.markwon.Markwon;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup;
public final class TextLinkifier {
public static final String TAG = TextLinkifier.class.getSimpleName();
// Looks for hashtags with characters from any language (\p{L}), numbers, or underscores
private static final Pattern HASHTAGS_PATTERN =
Pattern.compile("(#[\\p{L}0-9_]+)");
private TextLinkifier() {
}
/**
* Create web links for contents with an HTML description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after having linked the URLs with
* {@link HtmlCompat#fromHtml(String, int)}.
*
* @param textView the TextView to set the htmlBlock linked
* @param htmlBlock the htmlBlock to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
* will be called
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromHtmlBlock(@NonNull final TextView textView,
final String htmlBlock,
final int htmlCompatFlag,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
changeIntentsOfDescriptionLinks(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables);
}
/**
* Create web links for contents with a plain text description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after having linked the URLs with
* {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
*
* @param textView the TextView to set the plain text block linked
* @param plainTextBlock the block of plain text to be linked
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromPlainText(@NonNull final TextView textView,
final String plainTextBlock,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables);
}
/**
* Create web links for contents with a markdown description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after creating an {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
*
* @param textView the TextView to set the plain text block linked
* @param markdownBlock the block of markdown text to be linked
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromMarkdownText(@NonNull final TextView textView,
final String markdownBlock,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
final Markwon markwon = Markwon.builder(textView.getContext())
.usePlugin(LinkifyPlugin.create()).build();
changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo,
disposables);
}
/**
* Add click listeners which opens a search on hashtags in a plain text.
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link ClickableSpan} which opens
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
* in the service of the content.
*
* @param context the context to use
* @param spannableDescription the SpannableStringBuilder with the text of the
* content description
* @param relatedInfo used to search for the term in the correct service
*/
private static void addClickListenersOnHashtags(final Context context,
@NonNull final SpannableStringBuilder
spannableDescription,
final Info relatedInfo) {
final String descriptionText = spannableDescription.toString();
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
while (hashtagsMatches.find()) {
final int hashtagStart = hashtagsMatches.start(1);
final int hashtagEnd = hashtagsMatches.end(1);
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
// don't add a ClickableSpan if there is already one, which should be a part of an URL,
// already parsed before
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
ClickableSpan.class).length == 0) {
spannableDescription.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
NavigationHelper.openSearch(context, relatedInfo.getServiceId(),
parsedHashtag);
}
}, hashtagStart, hashtagEnd, 0);
}
}
}
/**
* Add click listeners which opens the popup player on timestamps in a plain text.
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link ClickableSpan} which opens the popup
* player at the time indicated in the timestamps.
*
* @param context the context to use
* @param spannableDescription the SpannableStringBuilder with the text of the
* content description
* @param relatedInfo what to open in the popup player when timestamps are clicked
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
*/
private static void addClickListenersOnTimestamps(final Context context,
@NonNull final SpannableStringBuilder
spannableDescription,
final Info relatedInfo,
final CompositeDisposable disposables) {
final String descriptionText = spannableDescription.toString();
final Matcher timestampsMatches =
TimestampExtractor.TIMESTAMPS_PATTERN.matcher(descriptionText);
while (timestampsMatches.find()) {
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
TimestampExtractor.getTimestampFromMatcher(
timestampsMatches,
descriptionText);
if (timestampMatchDTO == null) {
continue;
}
spannableDescription.setSpan(
new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
playOnPopup(
context,
relatedInfo.getUrl(),
relatedInfo.getService(),
timestampMatchDTO.seconds(),
disposables);
}
},
timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd(),
0);
}
}
/**
* Change links generated by libraries in the description of a content to a custom link action
* and add click listeners on timestamps in this description.
* <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
* a content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* This method will also add click listeners on timestamps in this description, which will play
* the content in the popup player at the time indicated in the timestamp, by using
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info,
* CompositeDisposable)} method and click listeners on hashtags, by using
* {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)},
* which will open a search on the current service with the hashtag.
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
*
* @param textView the TextView in which the converted CharSequence will be applied
* @param chars the CharSequence to be parsed
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
private static void changeIntentsOfDescriptionLinks(final TextView textView,
final CharSequence chars,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
disposables.add(Single.fromCallable(() -> {
final Context context = textView.getContext();
// add custom click actions on web links
final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
for (final URLSpan span : urls) {
final String url = span.getURL();
final ClickableSpan clickableSpan = new ClickableSpan() {
public void onClick(@NonNull final View view) {
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
new CompositeDisposable(), context, url)) {
ShareUtils.openUrlInBrowser(context, url, false);
}
}
};
textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span),
textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span));
textBlockLinked.removeSpan(span);
}
// add click actions on plain text timestamps only for description of contents,
// unneeded for meta-info or other TextViews
if (relatedInfo != null) {
if (relatedInfo instanceof StreamInfo) {
addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo,
disposables);
}
addClickListenersOnHashtags(context, textBlockLinked, relatedInfo);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked),
throwable -> {
Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars);
}));
}
private static void setTextViewCharSequence(@NonNull final TextView textView,
final CharSequence charSequence) {
textView.setText(charSequence);
textView.setMovementMethod(LinkMovementMethod.getInstance());
textView.setVisibility(View.VISIBLE);
}
}

View file

@ -0,0 +1,42 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.annotation.SuppressLint;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;
public class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) {
return false;
}
final TextView widget = (TextView) v;
final CharSequence text = widget.getText();
if (text instanceof Spanned) {
final Spanned buffer = (Spanned) text;
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(widget, event);
final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class);
if (links.length != 0) {
if (action == MotionEvent.ACTION_UP) {
links[0].onClick(widget);
}
// we handle events that intersect links, so return true
return true;
}
}
}
return false;
}
}

View file

@ -0,0 +1,36 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
final class HashtagLongPressClickableSpan extends LongPressClickableSpan {
@NonNull
private final Context context;
@NonNull
private final String parsedHashtag;
private final int relatedInfoServiceId;
HashtagLongPressClickableSpan(@NonNull final Context context,
@NonNull final String parsedHashtag,
final int relatedInfoServiceId) {
this.context = context;
this.parsedHashtag = parsedHashtag;
this.relatedInfoServiceId = relatedInfoServiceId;
}
@Override
public void onClick(@NonNull final View view) {
NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag);
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, parsedHashtag);
}
}

View file

@ -1,4 +1,4 @@
package org.schabi.newpipe.util.external_communication;
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.util.Log;

View file

@ -0,0 +1,12 @@
package org.schabi.newpipe.util.text;
import android.text.style.ClickableSpan;
import android.view.View;
import androidx.annotation.NonNull;
public abstract class LongPressClickableSpan extends ClickableSpan {
public abstract void onLongClick(@NonNull View view);
}

View file

@ -0,0 +1,77 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.os.Handler;
import android.os.Looper;
import android.text.Selection;
import android.text.Spannable;
import android.text.method.LinkMovementMethod;
import android.text.method.MovementMethod;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import android.widget.TextView;
import androidx.annotation.NonNull;
// Class adapted from https://stackoverflow.com/a/31786969
public class LongPressLinkMovementMethod extends LinkMovementMethod {
private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout();
private static LongPressLinkMovementMethod instance;
private Handler longClickHandler;
private boolean isLongPressed = false;
@Override
public boolean onTouchEvent(@NonNull final TextView widget,
@NonNull final Spannable buffer,
@NonNull final MotionEvent event) {
final int action = event.getAction();
if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) {
longClickHandler.removeCallbacksAndMessages(null);
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(widget, event);
final LongPressClickableSpan[] link = buffer.getSpans(offset, offset,
LongPressClickableSpan.class);
if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
if (longClickHandler != null) {
longClickHandler.removeCallbacksAndMessages(null);
}
if (!isLongPressed) {
link[0].onClick(widget);
}
isLongPressed = false;
} else {
Selection.setSelection(buffer, buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
if (longClickHandler != null) {
longClickHandler.postDelayed(() -> {
link[0].onLongClick(widget);
isLongPressed = true;
}, LONG_PRESS_TIME);
}
}
return true;
}
}
return super.onTouchEvent(widget, buffer, event);
}
public static MovementMethod getInstance() {
if (instance == null) {
instance = new LongPressLinkMovementMethod();
instance.longClickHandler = new Handler(Looper.myLooper());
}
return instance;
}
}

View file

@ -0,0 +1,369 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
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 java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.noties.markwon.Markwon;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class TextLinkifier {
public static final String TAG = TextLinkifier.class.getSimpleName();
// Looks for hashtags with characters from any language (\p{L}), numbers, or underscores
private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)");
public static final Consumer<TextView> SET_LINK_MOVEMENT_METHOD =
v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance());
private TextLinkifier() {
}
/**
* Create links for contents with an {@link Description} in the various possible formats.
* <p>
* This will call one of these three functions based on the format: {@link #fromHtml},
* {@link #fromMarkdown} or {@link #fromPlainText}.
*
* @param textView the TextView to set the htmlBlock linked
* @param description the htmlBlock to be linked
* @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)}
* will be called (not used for formats different than HTML)
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromDescription(@NonNull final TextView textView,
@NonNull final Description description,
final int htmlCompatFlag,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
switch (description.getType()) {
case Description.HTML:
TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag,
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
case Description.MARKDOWN:
TextLinkifier.fromMarkdown(textView, description.getContent(),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
case Description.PLAIN_TEXT: default:
TextLinkifier.fromPlainText(textView, description.getContent(),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
}
}
/**
* Create links for contents with an HTML description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after having linked the URLs with
* {@link HtmlCompat#fromHtml(String, int)}.
* </p>
*
* @param textView the {@link TextView} to set the the HTML string block linked
* @param htmlBlock the HTML string block to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String,
* int)} will be called
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromHtml(@NonNull final TextView textView,
@NonNull final String htmlBlock,
final int htmlCompatFlag,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
changeLinkIntents(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
}
/**
* Create links for contents with a plain text description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after having linked the URLs with
* {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
* </p>
*
* @param textView the {@link TextView} to set the plain text block linked
* @param plainTextBlock the block of plain text to be linked
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromPlainText(@NonNull final TextView textView,
@NonNull final String plainTextBlock,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
changeLinkIntents(textView, textView.getText(), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
}
/**
* Create links for contents with a markdown description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
* </p>
*
* @param textView the {@link TextView} to set the plain text block linked
* @param markdownBlock the block of markdown text to be linked
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromMarkdown(@NonNull final TextView textView,
@NonNull final String markdownBlock,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
final Markwon markwon = Markwon.builder(textView.getContext())
.usePlugin(LinkifyPlugin.create()).build();
changeLinkIntents(textView, markwon.toMarkdown(markdownBlock),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
}
/**
* Change links generated by libraries in the description of a content to a custom link action
* and add click listeners on timestamps in this description.
*
* <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
* a content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* </p>
*
* <p>
* This method will also add click listeners on timestamps in this description, which will play
* the content in the popup player at the time indicated in the timestamp, by using
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder,
* StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by
* using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder,
* StreamingService)}, which will open a search on the current service with the hashtag.
* </p>
*
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
* </p>
*
* @param textView the {@link TextView} to which the converted {@link CharSequence}
* will be applied
* @param chars the {@link CharSequence} to be parsed
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
private static void changeLinkIntents(@NonNull final TextView textView,
@NonNull final CharSequence chars,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
disposables.add(Single.fromCallable(() -> {
final Context context = textView.getContext();
// add custom click actions on web links
final SpannableStringBuilder textBlockLinked =
new SpannableStringBuilder(chars);
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(),
URLSpan.class);
for (final URLSpan span : urls) {
final String url = span.getURL();
final LongPressClickableSpan longPressClickableSpan =
new UrlLongPressClickableSpan(context, disposables, url);
textBlockLinked.setSpan(longPressClickableSpan,
textBlockLinked.getSpanStart(span),
textBlockLinked.getSpanEnd(span),
textBlockLinked.getSpanFlags(span));
textBlockLinked.removeSpan(span);
}
// add click actions on plain text timestamps only for description of contents,
// unneeded for meta-info or other TextViews
if (relatedInfoService != null) {
if (relatedStreamUrl != null) {
addClickListenersOnTimestamps(context, textBlockLinked,
relatedInfoService, relatedStreamUrl, disposables);
}
addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
textBlockLinked ->
setTextViewCharSequence(textView, textBlockLinked, onCompletion),
throwable -> {
Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars, onCompletion);
}));
}
/**
* Add click listeners which opens a search on hashtags in a plain text.
*
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link LongPressClickableSpan} which opens
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
* in the service of the content when pressed, and copy the hashtag to clipboard when
* long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}).
* </p>
*
* @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description
* @param relatedInfoService used to search for the term in the correct service
*/
private static void addClickListenersOnHashtags(
@NonNull final Context context,
@NonNull final SpannableStringBuilder spannableDescription,
@NonNull final StreamingService relatedInfoService) {
final String descriptionText = spannableDescription.toString();
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
while (hashtagsMatches.find()) {
final int hashtagStart = hashtagsMatches.start(1);
final int hashtagEnd = hashtagsMatches.end(1);
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
// Don't add a LongPressClickableSpan if there is already one, which should be a part
// of an URL, already parsed before
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
LongPressClickableSpan.class).length == 0) {
final int serviceId = relatedInfoService.getServiceId();
spannableDescription.setSpan(
new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId),
hashtagStart, hashtagEnd, 0);
}
}
}
/**
* Add click listeners which opens the popup player on timestamps in a plain text.
*
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the
* popup player at the time indicated in the timestamps and copy the timestamp in clipboard
* when long-pressed.
* </p>
*
* @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description
* @param relatedInfoService the service of the {@code relatedStreamUrl}
* @param relatedStreamUrl what to open in the popup player when timestamps are clicked
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
*/
private static void addClickListenersOnTimestamps(
@NonNull final Context context,
@NonNull final SpannableStringBuilder spannableDescription,
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables) {
final String descriptionText = spannableDescription.toString();
final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(
descriptionText);
while (timestampsMatches.find()) {
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText);
if (timestampMatchDTO == null) {
continue;
}
spannableDescription.setSpan(
new TimestampLongPressClickableSpan(context, descriptionText, disposables,
relatedInfoService, relatedStreamUrl, timestampMatchDTO),
timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd(),
0);
}
}
private static void setTextViewCharSequence(@NonNull final TextView textView,
@Nullable final CharSequence charSequence,
@Nullable final Consumer<TextView> onCompletion) {
textView.setText(charSequence);
textView.setVisibility(View.VISIBLE);
if (onCompletion != null) {
onCompletion.accept(textView);
}
}
}

View file

@ -1,4 +1,7 @@
package org.schabi.newpipe.util.external_communication;
package org.schabi.newpipe.util.text;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -15,17 +18,18 @@ public final class TimestampExtractor {
}
/**
* Get's a single timestamp from a matcher.
* Gets a single timestamp from a matcher.
*
* @param timestampMatches The matcher which was created using {@link #TIMESTAMPS_PATTERN}
* @param baseText The text where the pattern was applied to /
* where the matcher is based upon
* @return If a match occurred: a {@link TimestampMatchDTO} filled with information.<br/>
* If not <code>null</code>.
* @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN}
* @param baseText the text where the pattern was applied to / where the matcher is
* based upon
* @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise
* {@code null}.
*/
@Nullable
public static TimestampMatchDTO getTimestampFromMatcher(
final Matcher timestampMatches,
final String baseText) {
@NonNull final Matcher timestampMatches,
@NonNull final String baseText) {
int timestampStart = timestampMatches.start(1);
if (timestampStart == -1) {
timestampStart = timestampMatches.start(2);

View file

@ -0,0 +1,78 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
@NonNull
private final Context context;
@NonNull
private final String descriptionText;
@NonNull
private final CompositeDisposable disposables;
@NonNull
private final StreamingService relatedInfoService;
@NonNull
private final String relatedStreamUrl;
@NonNull
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
TimestampLongPressClickableSpan(
@NonNull final Context context,
@NonNull final String descriptionText,
@NonNull final CompositeDisposable disposables,
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
this.context = context;
this.descriptionText = descriptionText;
this.disposables = disposables;
this.relatedInfoService = relatedInfoService;
this.relatedStreamUrl = relatedStreamUrl;
this.timestampMatchDTO = timestampMatchDTO;
}
@Override
public void onClick(@NonNull final View view) {
playOnPopup(context, relatedStreamUrl, relatedInfoService,
timestampMatchDTO.seconds(), disposables);
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, getTimestampTextToCopy(
relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO));
}
@NonNull
private static String getTimestampTextToCopy(
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final String descriptionText,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
if (relatedInfoService == ServiceList.YouTube) {
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.SoundCloud
|| relatedInfoService == ServiceList.MediaCCC) {
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.PeerTube) {
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds();
}
// Return timestamp text for other services
return descriptionText.subSequence(timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd()).toString();
}
}

View file

@ -0,0 +1,38 @@
package org.schabi.newpipe.util.text;
import android.text.Layout;
import android.view.MotionEvent;
import android.widget.TextView;
import androidx.annotation.NonNull;
public final class TouchUtils {
private TouchUtils() {
}
/**
* Get the character offset on the closest line to the position pressed by the user of a
* {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}.
*
* @param textView the {@link TextView} on which the {@link MotionEvent} was fired
* @param event the {@link MotionEvent} which was fired
* @return the character offset on the closest line to the position pressed by the user
*/
public static int getOffsetForHorizontalLine(@NonNull final TextView textView,
@NonNull final MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= textView.getTotalPaddingLeft();
y -= textView.getTotalPaddingTop();
x += textView.getScrollX();
y += textView.getScrollY();
final Layout layout = textView.getLayout();
final int line = layout.getLineForVertical(y);
return layout.getOffsetForHorizontal(line, x);
}
}

View file

@ -0,0 +1,41 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
final class UrlLongPressClickableSpan extends LongPressClickableSpan {
@NonNull
private final Context context;
@NonNull
private final CompositeDisposable disposables;
@NonNull
private final String url;
UrlLongPressClickableSpan(@NonNull final Context context,
@NonNull final CompositeDisposable disposables,
@NonNull final String url) {
this.context = context;
this.disposables = disposables;
this.url = url;
}
@Override
public void onClick(@NonNull final View view) {
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
disposables, context, url)) {
ShareUtils.openUrlInBrowser(context, url, false);
}
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, url);
}
}

View file

@ -13,9 +13,10 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
/**
* An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)}
* when sharing selected text by using the {@code Share} command of the floating actions.
*
* <p>
* This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text
* from {@link AppCompatEditText} on EMUI devices.
* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing
* text from {@link AppCompatEditText} on EMUI devices.
* </p>
*/
public class NewPipeEditText extends AppCompatEditText {

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.views;
import android.content.Context;
import android.text.method.MovementMethod;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
@ -13,9 +14,11 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
/**
* An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)}
* when sharing selected text by using the {@code Share} command of the floating actions.
*
* <p>
* This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text
* from {@link AppCompatTextView} on EMUI devices.
* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing
* text from {@link AppCompatTextView} on EMUI devices and also to keep movement method set when a
* text change occurs, if the text cannot be selected and text links are clickable.
* </p>
*/
public class NewPipeTextView extends AppCompatTextView {
@ -34,6 +37,16 @@ public class NewPipeTextView extends AppCompatTextView {
super(context, attrs, defStyleAttr);
}
@Override
public void setText(final CharSequence text, final BufferType type) {
// We need to set again the movement method after a text change because Android resets the
// movement method to the default one in the case where the text cannot be selected and
// text links are clickable (which is the default case in NewPipe).
final MovementMethod movementMethod = this.getMovementMethod();
super.setText(text, type);
setMovementMethod(movementMethod);
}
@Override
public boolean onTextContextMenuItem(final int id) {
if (id == android.R.id.shareText) {