prefsKeys = PreferenceManager.getDefaultSharedPreferences(context)
- .getAll().keySet();
- for (final String key: prefsKeys) {
- // ACRA stores some info in the prefs during app initialization
- // which happens before this method is called. Therefore ignore ACRA-related keys.
- if (!key.toLowerCase().startsWith("acra")) {
- isFirstRun = false;
- break;
- }
- }
- if (isFirstRun == null) {
- isFirstRun = true;
- }
+ // check if the last used preference version is set
+ // to determine whether this is the first app run
+ final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(context)
+ .getInt(context.getString(R.string.last_used_preferences_version), -1);
+ final boolean isFirstRun = lastUsedPrefVersion == -1;
// first run migrations, then setDefaultValues, since the latter requires the correct types
SettingMigrations.runMigrationsIfNeeded(context, isFirstRun);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
index 0f25be63083..37335421d16 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
@@ -19,7 +19,7 @@
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ThemeHelper;
import java.util.List;
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
index e8491d52cda..147d20a360d 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
@@ -25,7 +25,7 @@
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
-import org.schabi.newpipe.util.PicassoHelper;
+import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.List;
import java.util.Vector;
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
index 215caaa3894..b7bafde7535 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
@@ -128,6 +128,20 @@ protected void migrate(@NonNull final Context context) {
}
};
+ public static final Migration MIGRATION_5_6 = new Migration(5, 6) {
+ @Override
+ protected void migrate(@NonNull final Context context) {
+ final boolean loadImages = sp.getBoolean("download_thumbnail_key", true);
+
+ sp.edit()
+ .putString(context.getString(R.string.image_quality_key),
+ context.getString(loadImages
+ ? R.string.image_quality_default
+ : R.string.image_quality_none_key))
+ .apply();
+ }
+ };
+
/**
* List of all implemented migrations.
*
@@ -140,12 +154,13 @@ protected void migrate(@NonNull final Context context) {
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
+ MIGRATION_5_6,
};
/**
* Version number for preferences. Must be incremented every time a migration is necessary.
*/
- private static final int VERSION = 5;
+ private static final int VERSION = 6;
public static void runMigrationsIfNeeded(@NonNull final Context context,
diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
index aae9cfca556..a1f563724ee 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
@@ -13,6 +13,7 @@
import com.google.android.material.snackbar.Snackbar;
import org.schabi.newpipe.R;
+import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.PermissionHelper;
import java.util.LinkedList;
@@ -26,7 +27,7 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro
addPreferencesFromResourceRegistry();
updateSeekOptions();
-
+ updateResolutionOptions();
listener = (sharedPreferences, key) -> {
// on M and above, if user chooses to minimise to popup player on exit
@@ -48,10 +49,84 @@ && getString(R.string.minimize_on_exit_key).equals(key)) {
}
} else if (getString(R.string.use_inexact_seek_key).equals(key)) {
updateSeekOptions();
+ } else if (getString(R.string.show_higher_resolutions_key).equals(key)) {
+ updateResolutionOptions();
}
};
}
+ /**
+ * Update default resolution, default popup resolution & mobile data resolution options.
+ *
+ * Show high resolutions when "Show higher resolution" option is enabled.
+ * Set default resolution to "best resolution" when "Show higher resolution" option
+ * is disabled.
+ */
+ private void updateResolutionOptions() {
+ final Resources resources = getResources();
+ final boolean showHigherResolutions = getPreferenceManager().getSharedPreferences()
+ .getBoolean(resources.getString(R.string.show_higher_resolutions_key), false);
+
+ // get sorted resolution lists
+ final List resolutionListDescriptions = ListHelper.getSortedResolutionList(
+ resources,
+ R.array.resolution_list_description,
+ R.array.high_resolution_list_descriptions,
+ showHigherResolutions);
+ final List resolutionListValues = ListHelper.getSortedResolutionList(
+ resources,
+ R.array.resolution_list_values,
+ R.array.high_resolution_list_values,
+ showHigherResolutions);
+ final List limitDataUsageResolutionValues = ListHelper.getSortedResolutionList(
+ resources,
+ R.array.limit_data_usage_values_list,
+ R.array.high_resolution_limit_data_usage_values_list,
+ showHigherResolutions);
+ final List limitDataUsageResolutionDescriptions = ListHelper
+ .getSortedResolutionList(resources,
+ R.array.limit_data_usage_description_list,
+ R.array.high_resolution_list_descriptions,
+ showHigherResolutions);
+
+ // get resolution preferences
+ final ListPreference defaultResolution = findPreference(
+ getString(R.string.default_resolution_key));
+ final ListPreference defaultPopupResolution = findPreference(
+ getString(R.string.default_popup_resolution_key));
+ final ListPreference mobileDataResolution = findPreference(
+ getString(R.string.limit_mobile_data_usage_key));
+
+ // update resolution preferences with new resolutions, entries & values for each
+ defaultResolution.setEntries(resolutionListDescriptions.toArray(new String[0]));
+ defaultResolution.setEntryValues(resolutionListValues.toArray(new String[0]));
+ defaultPopupResolution.setEntries(resolutionListDescriptions.toArray(new String[0]));
+ defaultPopupResolution.setEntryValues(resolutionListValues.toArray(new String[0]));
+ mobileDataResolution.setEntries(
+ limitDataUsageResolutionDescriptions.toArray(new String[0]));
+ mobileDataResolution.setEntryValues(limitDataUsageResolutionValues.toArray(new String[0]));
+
+ // if "Show higher resolution" option is disabled,
+ // set default resolution to "best resolution"
+ if (!showHigherResolutions) {
+ if (ListHelper.isHighResolutionSelected(defaultResolution.getValue(),
+ R.array.high_resolution_list_values,
+ resources)) {
+ defaultResolution.setValueIndex(0);
+ }
+ if (ListHelper.isHighResolutionSelected(defaultPopupResolution.getValue(),
+ R.array.high_resolution_list_values,
+ resources)) {
+ defaultPopupResolution.setValueIndex(0);
+ }
+ if (ListHelper.isHighResolutionSelected(mobileDataResolution.getValue(),
+ R.array.high_resolution_limit_data_usage_values_list,
+ resources)) {
+ mobileDataResolution.setValueIndex(0);
+ }
+ }
+ }
+
/**
* Update fast-forward/-rewind seek duration options
* according to language and inexact seek setting.
diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java
index c885b803cff..7dcbee56f37 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java
@@ -73,10 +73,8 @@ public void unsetSavedTabsListener() {
private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() {
return (sp, key) -> {
- if (savedTabsKey.equals(key)) {
- if (savedTabsChangeListener != null) {
- savedTabsChangeListener.onTabsChanged();
- }
+ if (savedTabsKey.equals(key) && savedTabsChangeListener != null) {
+ savedTabsChangeListener.onTabsChanged();
}
};
}
diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
index 48ae54284d9..74fc74c76dd 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
@@ -6,6 +6,7 @@
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
+import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -14,21 +15,27 @@
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.FilePickerActivityHelper;
-import java.io.File;
import java.io.IOException;
import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
public class StoredDirectoryHelper {
+ private static final String TAG = StoredDirectoryHelper.class.getSimpleName();
public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
- private File ioTree;
+ private Path ioTree;
private DocumentFile docTree;
private Context context;
@@ -40,7 +47,7 @@ public StoredDirectoryHelper(@NonNull final Context context, @NonNull final Uri
this.tag = tag;
if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) {
- this.ioTree = new File(URI.create(path.toString()));
+ ioTree = Paths.get(URI.create(path.toString()));
return;
}
@@ -64,13 +71,17 @@ public StoredFileHelper createFile(final String filename, final String mime) {
}
public StoredFileHelper createUniqueFile(final String name, final String mime) {
- final ArrayList matches = new ArrayList<>();
+ final List matches = new ArrayList<>();
final String[] filename = splitFilename(name);
- final String lcFilename = filename[0].toLowerCase();
+ final String lcFileName = filename[0].toLowerCase();
if (docTree == null) {
- for (final File file : ioTree.listFiles()) {
- addIfStartWith(matches, lcFilename, file.getName());
+ try (Stream stream = Files.list(ioTree)) {
+ matches.addAll(stream.map(path -> path.getFileName().toString().toLowerCase())
+ .filter(fileName -> fileName.startsWith(lcFileName))
+ .collect(Collectors.toList()));
+ } catch (final IOException e) {
+ Log.e(TAG, "Exception while traversing " + ioTree, e);
}
} else {
// warning: SAF file listing is very slow
@@ -82,37 +93,37 @@ public StoredFileHelper createUniqueFile(final String name, final String mime) {
final ContentResolver cr = context.getContentResolver();
try (Cursor cursor = cr.query(docTreeChildren, projection, selection,
- new String[]{lcFilename}, null)) {
+ new String[]{lcFileName}, null)) {
if (cursor != null) {
while (cursor.moveToNext()) {
- addIfStartWith(matches, lcFilename, cursor.getString(0));
+ addIfStartWith(matches, lcFileName, cursor.getString(0));
}
}
}
}
- if (matches.size() < 1) {
+ if (matches.isEmpty()) {
return createFile(name, mime, true);
- } else {
- // check if the filename is in use
- String lcName = name.toLowerCase();
- for (final String testName : matches) {
- if (testName.equals(lcName)) {
- lcName = null;
- break;
- }
- }
+ }
- // check if not in use
- if (lcName != null) {
- return createFile(name, mime, true);
+ // check if the filename is in use
+ String lcName = name.toLowerCase();
+ for (final String testName : matches) {
+ if (testName.equals(lcName)) {
+ lcName = null;
+ break;
}
}
+ // create file if filename not in use
+ if (lcName != null) {
+ return createFile(name, mime, true);
+ }
+
Collections.sort(matches, String::compareTo);
for (int i = 1; i < 1000; i++) {
- if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0) {
+ if (Collections.binarySearch(matches, makeFileName(lcFileName, i, filename[1])) < 0) {
return createFile(makeFileName(filename[0], i, filename[1]), mime, true);
}
}
@@ -141,11 +152,11 @@ private StoredFileHelper createFile(final String filename, final String mime,
}
public Uri getUri() {
- return docTree == null ? Uri.fromFile(ioTree) : docTree.getUri();
+ return docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri();
}
public boolean exists() {
- return docTree == null ? ioTree.exists() : docTree.exists();
+ return docTree == null ? Files.exists(ioTree) : docTree.exists();
}
/**
@@ -159,8 +170,8 @@ public boolean isDirect() {
/**
* Only using Java I/O. Creates the directory named by this abstract pathname, including any
- * necessary but nonexistent parent directories. Note that if this
- * operation fails it may have succeeded in creating some of the necessary
+ * necessary but nonexistent parent directories.
+ * Note that if this operation fails it may have succeeded in creating some of the necessary
* parent directories.
*
* @return true
if and only if the directory was created,
@@ -169,7 +180,12 @@ public boolean isDirect() {
*/
public boolean mkdirs() {
if (docTree == null) {
- return ioTree.exists() || ioTree.mkdirs();
+ try {
+ Files.createDirectories(ioTree);
+ } catch (final IOException e) {
+ Log.e(TAG, "Error while creating directories at " + ioTree, e);
+ }
+ return Files.exists(ioTree);
}
if (docTree.exists()) {
@@ -206,8 +222,8 @@ public String getTag() {
public Uri findFile(final String filename) {
if (docTree == null) {
- final File res = new File(ioTree, filename);
- return res.exists() ? Uri.fromFile(res) : null;
+ final Path res = ioTree.resolve(filename);
+ return Files.exists(res) ? Uri.fromFile(res.toFile()) : null;
}
final DocumentFile res = findFileSAFHelper(context, docTree, filename);
@@ -215,7 +231,7 @@ public Uri findFile(final String filename) {
}
public boolean canWrite() {
- return docTree == null ? ioTree.canWrite() : docTree.canWrite();
+ return docTree == null ? Files.isWritable(ioTree) : docTree.canWrite();
}
/**
@@ -230,14 +246,14 @@ public boolean isInvalidSafStorage() {
@NonNull
@Override
public String toString() {
- return (docTree == null ? Uri.fromFile(ioTree) : docTree.getUri()).toString();
+ return (docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri()).toString();
}
////////////////////
// Utils
///////////////////
- private static void addIfStartWith(final ArrayList list, @NonNull final String base,
+ private static void addIfStartWith(final List list, @NonNull final String base,
final String str) {
if (isNullOrEmpty(str)) {
return;
@@ -248,6 +264,12 @@ private static void addIfStartWith(final ArrayList list, @NonNull final
}
}
+ /**
+ * Splits the filename into the name and extension.
+ *
+ * @param filename The filename to split
+ * @return A String array with the name at index 0 and extension at index 1
+ */
private static String[] splitFilename(@NonNull final String filename) {
final int dotIndex = filename.lastIndexOf('.');
@@ -259,7 +281,7 @@ private static String[] splitFilename(@NonNull final String filename) {
}
private static String makeFileName(final String name, final int idx, final String ext) {
- return name.concat(" (").concat(String.valueOf(idx)).concat(")").concat(ext);
+ return name + "(" + idx + ")" + ext;
}
/**
diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java
index 1f0c914568d..5404426c432 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java
@@ -23,6 +23,9 @@
import java.io.IOException;
import java.io.Serializable;
import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import us.shandian.giga.io.FileStream;
import us.shandian.giga.io.FileStreamSAF;
@@ -36,7 +39,7 @@ public class StoredFileHelper implements Serializable {
private transient DocumentFile docFile;
private transient DocumentFile docTree;
- private transient File ioFile;
+ private transient Path ioPath;
private transient Context context;
protected String source;
@@ -49,7 +52,8 @@ public class StoredFileHelper implements Serializable {
public StoredFileHelper(final Context context, final Uri uri, final String mime) {
if (FilePickerActivityHelper.isOwnFileUri(context, uri)) {
- ioFile = Utils.getFileForUri(uri);
+ final File ioFile = Utils.getFileForUri(uri);
+ ioPath = ioFile.toPath();
source = Uri.fromFile(ioFile).toString();
} else {
docFile = DocumentFile.fromSingleUri(context, uri);
@@ -100,26 +104,18 @@ public StoredFileHelper(@Nullable final Uri parent, final String filename, final
this.srcType = this.docFile.getType();
}
- StoredFileHelper(final File location, final String filename, final String mime)
+ StoredFileHelper(final Path location, final String filename, final String mime)
throws IOException {
- this.ioFile = new File(location, filename);
+ ioPath = location.resolve(filename);
- if (this.ioFile.exists()) {
- if (!this.ioFile.isFile() && !this.ioFile.delete()) {
- throw new IOException("The filename is already in use by non-file entity "
- + "and cannot overwrite it");
- }
- } else {
- if (!this.ioFile.createNewFile()) {
- throw new IOException("Cannot create the file");
- }
- }
+ Files.deleteIfExists(ioPath);
+ Files.createFile(ioPath);
- this.source = Uri.fromFile(this.ioFile).toString();
- this.sourceTree = Uri.fromFile(location).toString();
+ source = Uri.fromFile(ioPath.toFile()).toString();
+ sourceTree = Uri.fromFile(location.toFile()).toString();
- this.srcName = ioFile.getName();
- this.srcType = mime;
+ srcName = ioPath.getFileName().toString();
+ srcType = mime;
}
public StoredFileHelper(final Context context, @Nullable final Uri parent,
@@ -129,12 +125,12 @@ public StoredFileHelper(final Context context, @Nullable final Uri parent,
if (path.getScheme() == null
|| path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) {
- this.ioFile = new File(URI.create(this.source));
+ this.ioPath = Paths.get(URI.create(this.source));
} else {
final DocumentFile file = DocumentFile.fromSingleUri(context, path);
if (file == null) {
- throw new RuntimeException("SAF not available");
+ throw new IOException("SAF not available");
}
this.context = context;
@@ -187,7 +183,7 @@ public SharpStream getStream() throws IOException {
assertValid();
if (docFile == null) {
- return new FileStream(ioFile);
+ return new FileStream(ioPath.toFile());
} else {
return new FileStreamSAF(context.getContentResolver(), docFile.getUri());
}
@@ -211,7 +207,7 @@ public boolean isInvalid() {
public Uri getUri() {
assertValid();
- return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri();
+ return docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri();
}
public Uri getParentUri() {
@@ -233,7 +229,12 @@ public boolean delete() {
return true;
}
if (docFile == null) {
- return ioFile.delete();
+ try {
+ return Files.deleteIfExists(ioPath);
+ } catch (final IOException e) {
+ Log.e(TAG, "Exception while deleting " + ioPath, e);
+ return false;
+ }
}
final boolean res = docFile.delete();
@@ -252,21 +253,30 @@ public boolean delete() {
public long length() {
assertValid();
- return docFile == null ? ioFile.length() : docFile.length();
+ if (docFile == null) {
+ try {
+ return Files.size(ioPath);
+ } catch (final IOException e) {
+ Log.e(TAG, "Exception while getting the size of " + ioPath, e);
+ return 0;
+ }
+ } else {
+ return docFile.length();
+ }
}
public boolean canWrite() {
if (source == null) {
return false;
}
- return docFile == null ? ioFile.canWrite() : docFile.canWrite();
+ return docFile == null ? Files.isWritable(ioPath) : docFile.canWrite();
}
public String getName() {
if (source == null) {
return srcName;
} else if (docFile == null) {
- return ioFile.getName();
+ return ioPath.getFileName().toString();
}
final String name = docFile.getName();
@@ -287,12 +297,11 @@ public String getTag() {
}
public boolean existsAsFile() {
- if (source == null || (docFile == null && ioFile == null)) {
+ if (source == null || (docFile == null && ioPath == null)) {
if (DEBUG) {
Log.d(TAG, "existsAsFile called but something is null: source = ["
+ (source == null ? "null => storage is invalid" : source)
- + "], docFile = [" + (docFile == null ? "null" : docFile)
- + "], ioFile = [" + (ioFile == null ? "null" : ioFile) + "]");
+ + "], docFile = [" + docFile + "], ioPath = [" + ioPath + "]");
}
return false;
}
@@ -300,7 +309,7 @@ public boolean existsAsFile() {
// WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow
// docFile.isVirtual() means it is non-physical?
return docFile == null
- ? (ioFile.exists() && ioFile.isFile())
+ ? Files.isRegularFile(ioPath)
: (docFile.exists() && docFile.isFile());
}
@@ -310,8 +319,10 @@ public boolean create() {
if (docFile == null) {
try {
- result = ioFile.createNewFile();
+ Files.createFile(ioPath);
+ result = true;
} catch (final IOException e) {
+ Log.e(TAG, "Exception while creating " + ioPath, e);
return false;
}
} else if (docTree == null) {
@@ -332,7 +343,8 @@ public boolean create() {
}
if (result) {
- source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString();
+ source = (docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri())
+ .toString();
srcName = getName();
srcType = getType();
}
@@ -352,7 +364,7 @@ public void invalidate() {
docTree = null;
docFile = null;
- ioFile = null;
+ ioPath = null;
context = null;
}
@@ -383,7 +395,7 @@ public boolean equals(final StoredFileHelper storage) {
}
if (this.isDirect()) {
- return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath());
+ return this.ioPath.equals(storage.ioPath);
}
return DocumentsContract.getDocumentId(this.docFile.getUri())
diff --git a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java
index 39a05acb313..90689052edf 100644
--- a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java
@@ -13,7 +13,7 @@
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.stream.AudioStream;
-import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
+import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
import java.io.Serializable;
import java.util.List;
@@ -75,15 +75,15 @@ public View getView(final int position, final View convertView, final ViewGroup
}
public static class AudioTracksWrapper implements Serializable {
- private final List> tracksList;
+ private final List> tracksList;
public AudioTracksWrapper(@NonNull final List> groupedAudioStreams,
@Nullable final Context context) {
this.tracksList = groupedAudioStreams.stream().map(streams ->
- new StreamSizeWrapper<>(streams, context)).collect(Collectors.toList());
+ new StreamInfoWrapper<>(streams, context)).collect(Collectors.toList());
}
- public List> getTracksList() {
+ public List> getTracksList() {
return tracksList;
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java
new file mode 100644
index 00000000000..8e8d3849007
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java
@@ -0,0 +1,151 @@
+package org.schabi.newpipe.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.StringRes;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs;
+import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
+
+import java.util.List;
+import java.util.Set;
+
+public final class ChannelTabHelper {
+ private ChannelTabHelper() {
+ }
+
+ /**
+ * @param tab the channel tab to check
+ * @return whether the tab should contain (playable) streams or not
+ */
+ public static boolean isStreamsTab(final String tab) {
+ switch (tab) {
+ case ChannelTabs.VIDEOS:
+ case ChannelTabs.TRACKS:
+ case ChannelTabs.SHORTS:
+ case ChannelTabs.LIVESTREAMS:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * @param tab the channel tab link handler to check
+ * @return whether the tab should contain (playable) streams or not
+ */
+ public static boolean isStreamsTab(final ListLinkHandler tab) {
+ final List contentFilters = tab.getContentFilters();
+ if (contentFilters.isEmpty()) {
+ return false; // this should never happen, but check just to be sure
+ } else {
+ return isStreamsTab(contentFilters.get(0));
+ }
+ }
+
+ @StringRes
+ private static int getShowTabKey(final String tab) {
+ switch (tab) {
+ case ChannelTabs.VIDEOS:
+ return R.string.show_channel_tabs_videos;
+ case ChannelTabs.TRACKS:
+ return R.string.show_channel_tabs_tracks;
+ case ChannelTabs.SHORTS:
+ return R.string.show_channel_tabs_shorts;
+ case ChannelTabs.LIVESTREAMS:
+ return R.string.show_channel_tabs_livestreams;
+ case ChannelTabs.CHANNELS:
+ return R.string.show_channel_tabs_channels;
+ case ChannelTabs.PLAYLISTS:
+ return R.string.show_channel_tabs_playlists;
+ case ChannelTabs.ALBUMS:
+ return R.string.show_channel_tabs_albums;
+ default:
+ return -1;
+ }
+ }
+
+ @StringRes
+ private static int getFetchFeedTabKey(final String tab) {
+ switch (tab) {
+ case ChannelTabs.VIDEOS:
+ return R.string.fetch_channel_tabs_videos;
+ case ChannelTabs.TRACKS:
+ return R.string.fetch_channel_tabs_tracks;
+ case ChannelTabs.SHORTS:
+ return R.string.fetch_channel_tabs_shorts;
+ case ChannelTabs.LIVESTREAMS:
+ return R.string.fetch_channel_tabs_livestreams;
+ default:
+ return -1;
+ }
+ }
+
+ @StringRes
+ public static int getTranslationKey(final String tab) {
+ switch (tab) {
+ case ChannelTabs.VIDEOS:
+ return R.string.channel_tab_videos;
+ case ChannelTabs.TRACKS:
+ return R.string.channel_tab_tracks;
+ case ChannelTabs.SHORTS:
+ return R.string.channel_tab_shorts;
+ case ChannelTabs.LIVESTREAMS:
+ return R.string.channel_tab_livestreams;
+ case ChannelTabs.CHANNELS:
+ return R.string.channel_tab_channels;
+ case ChannelTabs.PLAYLISTS:
+ return R.string.channel_tab_playlists;
+ case ChannelTabs.ALBUMS:
+ return R.string.channel_tab_albums;
+ default:
+ return R.string.unknown_content;
+ }
+ }
+
+ public static boolean showChannelTab(final Context context,
+ final SharedPreferences sharedPreferences,
+ @StringRes final int key) {
+ final Set enabledTabs = sharedPreferences.getStringSet(
+ context.getString(R.string.show_channel_tabs_key), null);
+ if (enabledTabs == null) {
+ return true; // default to true
+ } else {
+ return enabledTabs.contains(context.getString(key));
+ }
+ }
+
+ public static boolean showChannelTab(final Context context,
+ final SharedPreferences sharedPreferences,
+ final String tab) {
+ final int key = ChannelTabHelper.getShowTabKey(tab);
+ if (key == -1) {
+ return false;
+ }
+ return showChannelTab(context, sharedPreferences, key);
+ }
+
+ public static boolean fetchFeedChannelTab(final Context context,
+ final SharedPreferences sharedPreferences,
+ final ListLinkHandler tab) {
+ final List contentFilters = tab.getContentFilters();
+ if (contentFilters.isEmpty()) {
+ return false; // this should never happen, but check just to be sure
+ }
+
+ final int key = ChannelTabHelper.getFetchFeedTabKey(contentFilters.get(0));
+ if (key == -1) {
+ return false;
+ }
+
+ final Set enabledTabs = sharedPreferences.getStringSet(
+ context.getString(R.string.feed_fetch_channel_tabs_key), null);
+ if (enabledTabs == null) {
+ return true; // default to true
+ } else {
+ return enabledTabs.contains(context.getString(key));
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
index d5d472d6f28..07d0f516de2 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
@@ -36,17 +36,15 @@
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
-import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.MetaInfo;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.Page;
-import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
+import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
-import org.schabi.newpipe.extractor.feed.FeedExtractor;
-import org.schabi.newpipe.extractor.feed.FeedInfo;
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
+import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
@@ -127,28 +125,24 @@ public static Single getChannelInfo(final int serviceId, final Stri
ChannelInfo.getInfo(NewPipe.getService(serviceId), url)));
}
- public static Single> getMoreChannelItems(final int serviceId,
- final String url,
- final Page nextPage) {
+ public static Single getChannelTab(final int serviceId,
+ final ListLinkHandler listLinkHandler,
+ final boolean forceLoad) {
checkServiceId(serviceId);
- return Single.fromCallable(() ->
- ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
+ return checkCache(forceLoad, serviceId,
+ listLinkHandler.getUrl(), InfoItem.InfoType.CHANNEL,
+ Single.fromCallable(() ->
+ ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler)));
}
- public static Single> getFeedInfoFallbackToChannelInfo(
- final int serviceId, final String url) {
- final Maybe> maybeFeedInfo = Maybe.fromCallable(() -> {
- final StreamingService service = NewPipe.getService(serviceId);
- final FeedExtractor feedExtractor = service.getFeedExtractor(url);
-
- if (feedExtractor == null) {
- return null;
- }
-
- return FeedInfo.getInfo(feedExtractor);
- });
-
- return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true));
+ public static Single> getMoreChannelTabItems(
+ final int serviceId,
+ final ListLinkHandler listLinkHandler,
+ final Page nextPage) {
+ checkServiceId(serviceId);
+ return Single.fromCallable(() ->
+ ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId),
+ listLinkHandler, nextPage));
}
public static Single getCommentsInfo(final int serviceId, final String url,
@@ -229,7 +223,7 @@ private static Single checkCache(final boolean forceLoad,
load = actualLoadFromNetwork;
} else {
load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType),
- actualLoadFromNetwork.toMaybe())
+ actualLoadFromNetwork.toMaybe())
.firstElement() // Take the first valid
.toSingle();
}
@@ -240,10 +234,10 @@ private static Single checkCache(final boolean forceLoad,
/**
* Default implementation uses the {@link InfoCache} to get cached results.
*
- * @param the item type's class that extends {@link Info}
- * @param serviceId the service to load from
- * @param url the URL to load
- * @param infoType the {@link InfoItem.InfoType} of the item
+ * @param the item type's class that extends {@link Info}
+ * @param serviceId the service to load from
+ * @param url the URL to load
+ * @param infoType the {@link InfoItem.InfoType} of the item
* @return a {@link Single} that loads the item
*/
private static Maybe loadFromCache(final int serviceId, final String url,
@@ -274,11 +268,12 @@ public static boolean isCached(final int serviceId, final String url,
* Formats the text contained in the meta info list as HTML and puts it into the text view,
* while also making the separator visible. If the list is null or empty, or the user chose not
* to see meta information, both the text view and the separator are hidden
- * @param metaInfos a list of meta information, can be null or empty
- * @param metaInfoTextView the text view in which to show the formatted HTML
+ *
+ * @param metaInfos a list of meta information, can be null or empty
+ * @param metaInfoTextView the text view in which to show the formatted HTML
* @param metaInfoSeparator another view to be shown or hidden accordingly to the text view
- * @param disposables disposables created by the method are added here and their lifecycle
- * should be handled by the calling class
+ * @param disposables disposables created by the method are added here and their lifecycle
+ * should be handled by the calling class
*/
public static void showMetaInfoInTextView(@Nullable final List metaInfos,
final TextView metaInfoTextView,
@@ -287,7 +282,7 @@ public static void showMetaInfoInTextView(@Nullable final List metaInf
final Context context = metaInfoTextView.getContext();
if (metaInfos == null || metaInfos.isEmpty()
|| !PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
- context.getString(R.string.show_meta_info_key), true)) {
+ context.getString(R.string.show_meta_info_key), true)) {
metaInfoTextView.setVisibility(View.GONE);
metaInfoSeparator.setVisibility(View.GONE);
diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java
index edcb565a04b..bc15f3f0242 100644
--- a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java
+++ b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java
@@ -7,6 +7,7 @@
import org.schabi.newpipe.R;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class FilenameUtils {
@@ -51,7 +52,7 @@ public static String createFilename(final Context context, final String title) {
final Pattern pattern = Pattern.compile(charset);
- return createFilename(title, pattern, replacementChar);
+ return createFilename(title, pattern, Matcher.quoteReplacement(replacementChar));
}
/**
diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
index f45f3786da9..71071d9977f 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
@@ -4,6 +4,7 @@
import android.content.Context;
import android.content.SharedPreferences;
+import android.content.res.Resources;
import android.net.ConnectivityManager;
import androidx.annotation.NonNull;
@@ -45,10 +46,10 @@ public final class ListHelper {
List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA);
// Use a Set for better performance
private static final Set HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p");
- // Audio track types in order of priotity. 0=lowest, n=highest
+ // Audio track types in order of priority. 0=lowest, n=highest
private static final List AUDIO_TRACK_TYPE_RANKING =
List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL);
- // Audio track types in order of priotity when descriptive audio is preferred.
+ // Audio track types in order of priority when descriptive audio is preferred.
private static final List AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE =
List.of(AudioTrackType.ORIGINAL, AudioTrackType.DUBBED, AudioTrackType.DESCRIPTIVE);
@@ -188,13 +189,16 @@ public static List getUrlAndNonTorrentStreams(
/**
* Return a {@link Stream} list which only contains streams which can be played by the player.
- *
- * Some formats are not supported. For more info, see {@link #SUPPORTED_ITAG_IDS}.
- * Torrent streams are also removed, because they cannot be retrieved.
+ *
+ *
+ * Some formats are not supported, see {@link #SUPPORTED_ITAG_IDS} for more details.
+ * Torrent streams are also removed, because they cannot be retrieved, like OPUS streams using
+ * HLS as their delivery method, since they are not supported by ExoPlayer.
+ *
*
* @param the item type's class that extends {@link Stream}
* @param streamList the original stream list
- * @param serviceId
+ * @param serviceId the service ID from which the streams' list comes from
* @return a stream list which only contains streams that can be played the player
*/
@NonNull
@@ -203,6 +207,8 @@ public static List getPlayableStreams(
final int youtubeServiceId = YouTube.getServiceId();
return getFilteredStreamList(streamList,
stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT
+ && (stream.getDeliveryMethod() != DeliveryMethod.HLS
+ || stream.getFormat() != MediaFormat.OPUS)
&& (serviceId != youtubeServiceId
|| stream.getItagItem() == null
|| SUPPORTED_ITAG_IDS.contains(stream.getItagItem().id)));
@@ -239,6 +245,41 @@ public static List getSortedStreamVideosList(
videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams);
}
+ /**
+ * Get a sorted list containing a set of default resolution info
+ * and additional resolution info if showHigherResolutions is true.
+ *
+ * @param resources the resources to get the resolutions from
+ * @param defaultResolutionKey the settings key of the default resolution
+ * @param additionalResolutionKey the settings key of the additional resolutions
+ * @param showHigherResolutions if higher resolutions should be included in the sorted list
+ * @return a sorted list containing the default and maybe additional resolutions
+ */
+ public static List getSortedResolutionList(
+ final Resources resources,
+ final int defaultResolutionKey,
+ final int additionalResolutionKey,
+ final boolean showHigherResolutions) {
+ final List resolutions = new ArrayList<>(Arrays.asList(
+ resources.getStringArray(defaultResolutionKey)));
+ if (!showHigherResolutions) {
+ return resolutions;
+ }
+ final List additionalResolutions = Arrays.asList(
+ resources.getStringArray(additionalResolutionKey));
+ // keep "best resolution" at the top
+ resolutions.addAll(1, additionalResolutions);
+ return resolutions;
+ }
+
+ public static boolean isHighResolutionSelected(final String selectedResolution,
+ final int additionalResolutionKey,
+ final Resources resources) {
+ return Arrays.asList(resources.getStringArray(
+ additionalResolutionKey))
+ .contains(selectedResolution);
+ }
+
/**
* Filter the list of audio streams and return a list with the preferred stream for
* each audio track. Streams are sorted with the preferred language in the first position.
@@ -259,7 +300,9 @@ public static List getFilteredAudioStreams(
final Comparator cmp = getAudioFormatComparator(context);
for (final AudioStream stream : audioStreams) {
- if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT) {
+ if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT
+ || (stream.getDeliveryMethod() == DeliveryMethod.HLS
+ && stream.getFormat() == MediaFormat.OPUS)) {
continue;
}
@@ -653,7 +696,7 @@ private static int compareVideoStreamResolution(@NonNull final String r1,
}
}
- private static boolean isLimitingDataUsage(final Context context) {
+ static boolean isLimitingDataUsage(@NonNull final Context context) {
return getResolutionLimit(context) != null;
}
@@ -695,7 +738,7 @@ public static boolean isMeteredNetwork(@NonNull final Context context) {
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate.
*
- * The prefered stream will be ordered last.
+ * The preferred stream will be ordered last.
*
* @param context app context
* @return Comparator
@@ -710,7 +753,7 @@ private static Comparator getAudioFormatComparator(
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate.
*
- * The prefered stream will be ordered last.
+ * The preferred stream will be ordered last.
*
* @param defaultFormat the default format to look for
* @param limitDataUsage choose low bitrate audio stream
@@ -752,7 +795,7 @@ static Comparator getAudioFormatComparator(
* Language is English
*
*
- * The prefered track will be ordered last.
+ * The preferred track will be ordered last.
*
* @param context App context
* @return Comparator
@@ -789,7 +832,7 @@ private static Comparator getAudioTrackComparator(
* Language is English
*
*
- * The prefered track will be ordered last.
+ * The preferred track will be ordered last.
*
* @param preferredLanguage Preferred audio stream language
* @param preferOriginalAudio Get the original audio track regardless of its language
diff --git a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java
new file mode 100644
index 00000000000..9727c808300
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java
@@ -0,0 +1,90 @@
+package org.schabi.newpipe.util;
+
+import android.content.Context;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.preference.PreferenceManager;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.databinding.PlaylistControlBinding;
+import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
+import org.schabi.newpipe.player.PlayerType;
+
+/**
+ * Utility class for play buttons and their respective click listeners.
+ */
+public final class PlayButtonHelper {
+
+ private PlayButtonHelper() {
+ // utility class
+ }
+
+ /**
+ * Initialize {@link android.view.View.OnClickListener OnClickListener}
+ * and {@link android.view.View.OnLongClickListener OnLongClickListener} for playlist control
+ * buttons defined in {@link R.layout#playlist_control}.
+ *
+ * @param activity The activity to use for the {@link android.widget.Toast Toast}.
+ * @param playlistControlBinding The binding of the
+ * {@link R.layout#playlist_control playlist control layout}.
+ * @param fragment The fragment to get the play queue from.
+ */
+ public static void initPlaylistControlClickListener(
+ @NonNull final AppCompatActivity activity,
+ @NonNull final PlaylistControlBinding playlistControlBinding,
+ @NonNull final PlaylistControlViewHolder fragment) {
+ // click listener
+ playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> {
+ NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue());
+ showHoldToAppendToastIfNeeded(activity);
+ });
+ playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> {
+ NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false);
+ showHoldToAppendToastIfNeeded(activity);
+ });
+ playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> {
+ NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false);
+ showHoldToAppendToastIfNeeded(activity);
+ });
+
+ // long click listener
+ playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
+ NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP);
+ return true;
+ });
+ playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
+ NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO);
+ return true;
+ });
+ }
+
+ /**
+ * Show the "hold to append" toast if the corresponding preference is enabled.
+ *
+ * @param context The context to show the toast.
+ */
+ private static void showHoldToAppendToastIfNeeded(@NonNull final Context context) {
+ if (shouldShowHoldToAppendTip(context)) {
+ Toast.makeText(context, R.string.hold_to_append, Toast.LENGTH_SHORT).show();
+ }
+
+ }
+
+ /**
+ * Check if the "hold to append" toast should be shown.
+ *
+ *
+ * The tip is shown if the corresponding preference is enabled.
+ * This is the default behaviour.
+ *
+ *
+ * @param context The context to get the preference.
+ * @return {@code true} if the tip should be shown, {@code false} otherwise.
+ */
+ public static boolean shouldShowHoldToAppendTip(@NonNull final Context context) {
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.show_hold_to_append_key), true);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
index e7fd2d4a4bc..75d9a3892f2 100644
--- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
+import android.content.Context;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -7,15 +9,16 @@
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.VideoStream;
-import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
+import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
+import java.util.Comparator;
import java.util.List;
public class SecondaryStreamHelper {
private final int position;
- private final StreamSizeWrapper streams;
+ private final StreamInfoWrapper streams;
- public SecondaryStreamHelper(@NonNull final StreamSizeWrapper streams,
+ public SecondaryStreamHelper(@NonNull final StreamInfoWrapper streams,
final T selectedStream) {
this.streams = streams;
this.position = streams.getStreamsList().indexOf(selectedStream);
@@ -25,14 +28,19 @@ public SecondaryStreamHelper(@NonNull final StreamSizeWrapper streams,
}
/**
- * Find the correct audio stream for the desired video stream.
+ * Finds an audio stream compatible with the provided video-only stream, so that the two streams
+ * can be combined in a single file by the downloader. If there are multiple available audio
+ * streams, chooses either the highest or the lowest quality one based on
+ * {@link ListHelper#isLimitingDataUsage(Context)}.
*
+ * @param context Android context
* @param audioStreams list of audio streams
- * @param videoStream desired video ONLY stream
- * @return selected audio stream or null if a candidate was not found
+ * @param videoStream desired video-ONLY stream
+ * @return the selected audio stream or null if a candidate was not found
*/
@Nullable
- public static AudioStream getAudioStreamFor(@NonNull final List audioStreams,
+ public static AudioStream getAudioStreamFor(@NonNull final Context context,
+ @NonNull final List audioStreams,
@NonNull final VideoStream videoStream) {
final MediaFormat mediaFormat = videoStream.getFormat();
if (mediaFormat == null) {
@@ -41,33 +49,36 @@ public static AudioStream getAudioStreamFor(@NonNull final List aud
switch (mediaFormat) {
case WEBM:
- case MPEG_4:// ¿is mpeg-4 DASH?
+ case MPEG_4: // Is MPEG-4 DASH?
break;
default:
return null;
}
- final boolean m4v = (mediaFormat == MediaFormat.MPEG_4);
+ final boolean m4v = mediaFormat == MediaFormat.MPEG_4;
+ final boolean isLimitingDataUsage = ListHelper.isLimitingDataUsage(context);
+
+ Comparator comparator = ListHelper.getAudioFormatComparator(
+ m4v ? MediaFormat.M4A : MediaFormat.WEBMA, isLimitingDataUsage);
+ int preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
+ audioStreams, comparator);
- for (final AudioStream audio : audioStreams) {
- if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) {
- return audio;
+ if (preferredAudioStreamIndex == -1) {
+ if (m4v) {
+ return null;
}
- }
- if (m4v) {
- return null;
- }
+ comparator = ListHelper.getAudioFormatComparator(
+ MediaFormat.WEBMA_OPUS, isLimitingDataUsage);
+ preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
+ audioStreams, comparator);
- // retry, but this time in reverse order
- for (int i = audioStreams.size() - 1; i >= 0; i--) {
- final AudioStream audio = audioStreams.get(i);
- if (audio.getFormat() == MediaFormat.WEBMA_OPUS) {
- return audio;
+ if (preferredAudioStreamIndex == -1) {
+ return null;
}
}
- return null;
+ return audioStreams.get(preferredAudioStreamIndex);
}
public T getStream() {
diff --git a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
index 3c901aacb51..91dc5f35b93 100644
--- a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
+++ b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
@@ -309,7 +309,7 @@ public static void clearStateFiles() {
}
/**
- * Used for describe how to save/read the objects.
+ * Used for describing how to save/read the objects.
*
* Queue was chosen by its FIFO property.
*/
diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java
index 2eb63ff41c8..2eeb14b1b41 100644
--- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
+import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
+
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
@@ -11,21 +13,25 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
import androidx.collection.SparseArrayCompat;
import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.MediaFormat;
+import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream;
+import org.schabi.newpipe.extractor.utils.Utils;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
+import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
@@ -41,7 +47,7 @@
*/
public class StreamItemAdapter extends BaseAdapter {
@NonNull
- private final StreamSizeWrapper streamsWrapper;
+ private final StreamInfoWrapper streamsWrapper;
@NonNull
private final SparseArrayCompat> secondaryStreams;
@@ -53,7 +59,7 @@ public class StreamItemAdapter extends BaseA
private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream;
public StreamItemAdapter(
- @NonNull final StreamSizeWrapper streamsWrapper,
+ @NonNull final StreamInfoWrapper streamsWrapper,
@NonNull final SparseArrayCompat> secondaryStreams
) {
this.streamsWrapper = streamsWrapper;
@@ -63,7 +69,7 @@ public StreamItemAdapter(
checkHasAnyVideoOnlyStreamWithNoSecondaryStream();
}
- public StreamItemAdapter(final StreamSizeWrapper streamsWrapper) {
+ public StreamItemAdapter(final StreamInfoWrapper streamsWrapper) {
this(streamsWrapper, new SparseArrayCompat<>(0));
}
@@ -121,7 +127,7 @@ private View getCustomView(final int position,
final TextView sizeView = convertView.findViewById(R.id.stream_size);
final T stream = getItem(position);
- final MediaFormat mediaFormat = stream.getFormat();
+ final MediaFormat mediaFormat = streamsWrapper.getFormat(position);
int woSoundIconVisibility = View.GONE;
String qualityString;
@@ -147,8 +153,6 @@ private View getCustomView(final int position,
final AudioStream audioStream = ((AudioStream) stream);
if (audioStream.getAverageBitrate() > 0) {
qualityString = audioStream.getAverageBitrate() + "kbps";
- } else if (mediaFormat != null) {
- qualityString = mediaFormat.getName();
} else {
qualityString = context.getString(R.string.unknown_quality);
}
@@ -221,46 +225,58 @@ private boolean checkHasAnyVideoOnlyStreamWithNoSecondaryStream() {
*
* @param the stream type's class extending {@link Stream}
*/
- public static class StreamSizeWrapper implements Serializable {
- private static final StreamSizeWrapper EMPTY =
- new StreamSizeWrapper<>(Collections.emptyList(), null);
+ public static class StreamInfoWrapper implements Serializable {
+ private static final StreamInfoWrapper EMPTY =
+ new StreamInfoWrapper<>(Collections.emptyList(), null);
private static final int SIZE_UNSET = -2;
private final List streamsList;
private final long[] streamSizes;
+ private final MediaFormat[] streamFormats;
private final String unknownSize;
- public StreamSizeWrapper(@NonNull final List streamList,
+ public StreamInfoWrapper(@NonNull final List streamList,
@Nullable final Context context) {
this.streamsList = streamList;
this.streamSizes = new long[streamsList.size()];
this.unknownSize = context == null
? "--.-" : context.getString(R.string.unknown_content);
-
- resetSizes();
+ this.streamFormats = new MediaFormat[streamsList.size()];
+ resetInfo();
}
/**
- * Helper method to fetch the sizes of all the streams in a wrapper.
+ * Helper method to fetch the sizes and missing media formats
+ * of all the streams in a wrapper.
*
* @param the stream type's class extending {@link Stream}
* @param streamsWrapper the wrapper
* @return a {@link Single} that returns a boolean indicating if any elements were changed
*/
@NonNull
- public static Single fetchSizeForWrapper(
- final StreamSizeWrapper streamsWrapper) {
+ public static Single fetchMoreInfoForWrapper(
+ final StreamInfoWrapper streamsWrapper) {
final Callable fetchAndSet = () -> {
boolean hasChanged = false;
for (final X stream : streamsWrapper.getStreamsList()) {
- if (streamsWrapper.getSizeInBytes(stream) > SIZE_UNSET) {
+ final boolean changeSize = streamsWrapper.getSizeInBytes(stream) <= SIZE_UNSET;
+ final boolean changeFormat = stream.getFormat() == null;
+ if (!changeSize && !changeFormat) {
continue;
}
-
- final long contentLength = DownloaderImpl.getInstance().getContentLength(
- stream.getContent());
- streamsWrapper.setSize(stream, contentLength);
- hasChanged = true;
+ final Response response = DownloaderImpl.getInstance()
+ .head(stream.getContent());
+ if (changeSize) {
+ final String contentLength = response.getHeader("Content-Length");
+ if (!isNullOrEmpty(contentLength)) {
+ streamsWrapper.setSize(stream, Long.parseLong(contentLength));
+ hasChanged = true;
+ }
+ }
+ if (changeFormat) {
+ hasChanged = retrieveMediaFormat(stream, streamsWrapper, response)
+ || hasChanged;
+ }
}
return hasChanged;
};
@@ -271,13 +287,149 @@ public static Single fetchSizeForWrapper(
.onErrorReturnItem(true);
}
- public void resetSizes() {
+ /**
+ * Try to retrieve the {@link MediaFormat} for a stream from the request headers.
+ *
+ * @param the stream type to get the {@link MediaFormat} for
+ * @param stream the stream to find the {@link MediaFormat} for
+ * @param streamsWrapper the wrapper to store the found {@link MediaFormat} in
+ * @param response the response of the head request for the given stream
+ * @return {@code true} if the media format could be retrieved; {@code false} otherwise
+ */
+ @VisibleForTesting
+ public static boolean retrieveMediaFormat(
+ @NonNull final X stream,
+ @NonNull final StreamInfoWrapper streamsWrapper,
+ @NonNull final Response response) {
+ return retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response)
+ || retrieveMediaFormatFromContentDispositionHeader(
+ stream, streamsWrapper, response)
+ || retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response);
+ }
+
+ @VisibleForTesting
+ public static boolean retrieveMediaFormatFromFileTypeHeaders(
+ @NonNull final X stream,
+ @NonNull final StreamInfoWrapper streamsWrapper,
+ @NonNull final Response response) {
+ // try to use additional headers from CDNs or servers,
+ // e.g. x-amz-meta-file-type (e.g. for SoundCloud)
+ final List keys = response.responseHeaders().keySet().stream()
+ .filter(k -> k.endsWith("file-type")).collect(Collectors.toList());
+ if (!keys.isEmpty()) {
+ for (final String key : keys) {
+ final String suffix = response.getHeader(key);
+ final MediaFormat format = MediaFormat.getFromSuffix(suffix);
+ if (format != null) {
+ streamsWrapper.setFormat(stream, format);
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Retrieve a {@link MediaFormat} from a HTTP Content-Disposition header
+ * for a stream and store the info in a wrapper.
+ * @see
+ *
+ * mdn Web Docs for the HTTP Content-Disposition Header
+ * @param stream the stream to get the {@link MediaFormat} for
+ * @param streamsWrapper the wrapper to store the {@link MediaFormat} in
+ * @param response the response to get the Content-Disposition header from
+ * @return {@code true} if the {@link MediaFormat} could be retrieved from the response;
+ * otherwise {@code false}
+ * @param
+ */
+ @VisibleForTesting
+ public static boolean retrieveMediaFormatFromContentDispositionHeader(
+ @NonNull final X stream,
+ @NonNull final StreamInfoWrapper streamsWrapper,
+ @NonNull final Response response) {
+ // parse the Content-Disposition header,
+ // see
+ // there can be two filename directives
+ String contentDisposition = response.getHeader("Content-Disposition");
+ if (contentDisposition == null) {
+ return false;
+ }
+ try {
+ contentDisposition = Utils.decodeUrlUtf8(contentDisposition);
+ final String[] parts = contentDisposition.split(";");
+ for (String part : parts) {
+ final String fileName;
+ part = part.trim();
+
+ // extract the filename
+ if (part.startsWith("filename=")) {
+ // remove directive and decode
+ fileName = Utils.decodeUrlUtf8(part.substring(9));
+ } else if (part.startsWith("filename*=")) {
+ fileName = Utils.decodeUrlUtf8(part.substring(10));
+ } else {
+ continue;
+ }
+
+ // extract the file extension / suffix
+ final String[] p = fileName.split("\\.");
+ String suffix = p[p.length - 1];
+ if (suffix.endsWith("\"") || suffix.endsWith("'")) {
+ // remove trailing quotes if present, end index is exclusive
+ suffix = suffix.substring(0, suffix.length() - 1);
+ }
+
+ // get the corresponding media format
+ final MediaFormat format = MediaFormat.getFromSuffix(suffix);
+ if (format != null) {
+ streamsWrapper.setFormat(stream, format);
+ return true;
+ }
+ }
+ } catch (final Exception ignored) {
+ // fail silently
+ }
+ return false;
+ }
+
+ @VisibleForTesting
+ public static boolean retrieveMediaFormatFromContentTypeHeader(
+ @NonNull final X stream,
+ @NonNull final StreamInfoWrapper streamsWrapper,
+ @NonNull final Response response) {
+ // try to get the format by content type
+ // some mime types are not unique for every format, those are omitted
+ final String contentTypeHeader = response.getHeader("Content-Type");
+ if (contentTypeHeader == null) {
+ return false;
+ }
+
+ @Nullable MediaFormat foundFormat = null;
+ for (final MediaFormat format : MediaFormat.getAllFromMimeType(contentTypeHeader)) {
+ if (foundFormat == null) {
+ foundFormat = format;
+ } else if (foundFormat.id != format.id) {
+ return false;
+ }
+ }
+ if (foundFormat != null) {
+ streamsWrapper.setFormat(stream, foundFormat);
+ return true;
+ }
+ return false;
+ }
+
+ public void resetInfo() {
Arrays.fill(streamSizes, SIZE_UNSET);
+ for (int i = 0; i < streamsList.size(); i++) {
+ streamFormats[i] = streamsList.get(i) == null // test for invalid streams
+ ? null : streamsList.get(i).getFormat();
+ }
}
- public static StreamSizeWrapper empty() {
+ public static StreamInfoWrapper empty() {
//noinspection unchecked
- return (StreamSizeWrapper