From cfd77800d6641b46949d4e33eb24c394ccfc7453 Mon Sep 17 00:00:00 2001
From: MarcaD <152095496+MarcaDian@users.noreply.github.com>
Date: Thu, 24 Jul 2025 10:28:16 +0300
Subject: [PATCH] feat(YouTube - External downloads): Improve the selection of
the external downloader package (#5504)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
---
.../app/revanced/extension/shared/Utils.java | 22 +
.../CustomDialogListPreference.java | 4 +-
.../ResettableEditTextPreference.java | 13 -
.../youtube/patches/DownloadsPatch.java | 26 +-
.../extension/youtube/settings/Settings.java | 2 +-
.../CustomVideoSpeedListPreference.java | 10 +-
.../ExternalDownloaderPreference.java | 444 ++++++++++++++++++
.../SegmentPlaybackController.java | 5 +-
.../interaction/downloads/DownloadsPatch.kt | 6 +-
.../resources/addresources/values/strings.xml | 9 +-
10 files changed, 493 insertions(+), 48 deletions(-)
create mode 100644 extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPreference.java
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
index 609d99b0b..e84ac36a2 100644
--- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
@@ -1438,6 +1438,28 @@ public class Utils {
);
}
+ /**
+ * Converts a percentage of the screen height to actual device pixels.
+ *
+ * @param percentage The percentage of the screen height (e.g., 30 for 30%).
+ * @return The device pixel value corresponding to the percentage of screen height.
+ */
+ public static int percentageHeightToPixels(int percentage) {
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ return (int) (metrics.heightPixels * (percentage / 100.0f));
+ }
+
+ /**
+ * Converts a percentage of the screen width to actual device pixels.
+ *
+ * @param percentage The percentage of the screen width (e.g., 30 for 30%).
+ * @return The device pixel value corresponding to the percentage of screen width.
+ */
+ public static int percentageWidthToPixels(int percentage) {
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ return (int) (metrics.widthPixels * (percentage / 100.0f));
+ }
+
/**
* Adjusts the brightness of a color by lightening or darkening it based on the given factor.
*
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java
index 46ed1815b..4d0c1d5c1 100644
--- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/CustomDialogListPreference.java
@@ -1,7 +1,5 @@
package app.revanced.extension.shared.settings.preference;
-import static app.revanced.extension.shared.Utils.dipToPixels;
-
import android.app.Dialog;
import android.content.Context;
import android.os.Bundle;
@@ -26,7 +24,7 @@ public class CustomDialogListPreference extends ListPreference {
/**
* Custom ArrayAdapter to handle checkmark visibility.
*/
- private static class ListPreferenceArrayAdapter extends ArrayAdapter {
+ public static class ListPreferenceArrayAdapter extends ArrayAdapter {
private static class SubViewDataContainer {
ImageView checkIcon;
View placeholder;
diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
index 9338029d4..13de26bfa 100644
--- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
@@ -1,28 +1,15 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
-import static app.revanced.extension.shared.Utils.dipToPixels;
import android.app.Dialog;
import android.content.Context;
-import android.graphics.Color;
-import android.graphics.drawable.Drawable;
-import android.graphics.drawable.LayerDrawable;
-import android.graphics.drawable.shapes.RectShape;
-import android.graphics.drawable.shapes.RoundRectShape;
-import android.graphics.drawable.ShapeDrawable;
-import android.graphics.Paint.Style;
import android.os.Bundle;
import android.preference.EditTextPreference;
-import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Pair;
-import android.view.ViewGroup;
-import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
-import android.widget.LinearLayout;
-import android.widget.TextView;
import androidx.annotation.Nullable;
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java
index 6da31b6a4..7950c8b21 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/DownloadsPatch.java
@@ -1,17 +1,15 @@
package app.revanced.extension.youtube.patches;
+import static app.revanced.extension.youtube.settings.preference.ExternalDownloaderPreference.showDialogIfAppIsNotInstalled;
+
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
-import android.content.pm.PackageManager;
-
-import androidx.annotation.NonNull;
import java.lang.ref.WeakReference;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
-import app.revanced.extension.shared.StringRef;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
@@ -36,7 +34,7 @@ public final class DownloadsPatch {
*
* Appears to always be called from the main thread.
*/
- public static boolean inAppDownloadButtonOnClick(@NonNull String videoId) {
+ public static boolean inAppDownloadButtonOnClick(String videoId) {
try {
if (!Settings.EXTERNAL_DOWNLOADER_ACTION_BUTTON.get()) {
return false;
@@ -48,6 +46,9 @@ public final class DownloadsPatch {
boolean isActivityContext = true;
if (context == null) {
// Utils context is the application context, and not an activity context.
+ //
+ // Edit: This check may no longer be needed since YT can now
+ // only be launched from the main Activity (embedded usage in other apps no longer works).
context = Utils.getContext();
isActivityContext = false;
}
@@ -64,8 +65,7 @@ public final class DownloadsPatch {
* @param isActivityContext If the context parameter is for an Activity. If this is false, then
* the downloader is opened as a new task (which forces YT to minimize).
*/
- public static void launchExternalDownloader(@NonNull String videoId,
- @NonNull Context context, boolean isActivityContext) {
+ public static void launchExternalDownloader(String videoId, Context context, boolean isActivityContext) {
try {
Objects.requireNonNull(videoId);
Logger.printDebug(() -> "Launching external downloader with context: " + context);
@@ -73,16 +73,8 @@ public final class DownloadsPatch {
// Trim string to avoid any accidental whitespace.
var downloaderPackageName = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME.get().trim();
- boolean packageEnabled = false;
- try {
- packageEnabled = context.getPackageManager().getApplicationInfo(downloaderPackageName, 0).enabled;
- } catch (PackageManager.NameNotFoundException error) {
- Logger.printDebug(() -> "External downloader could not be found: " + error);
- }
-
- // If the package is not installed, show the toast
- if (!packageEnabled) {
- Utils.showToastLong(StringRef.str("revanced_external_downloader_not_installed_warning", downloaderPackageName));
+ // If the package is not installed, show a dialog.
+ if (showDialogIfAppIsNotInstalled(context, downloaderPackageName)) {
return;
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/Settings.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/Settings.java
index b57a54523..edeb7ca0b 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/Settings.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/Settings.java
@@ -191,7 +191,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_external_downloader", FALSE);
public static final BooleanSetting EXTERNAL_DOWNLOADER_ACTION_BUTTON = new BooleanSetting("revanced_external_downloader_action_button", FALSE);
public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME = new StringSetting("revanced_external_downloader_name",
- "org.schabi.newpipe" /* NewPipe */, parentsAny(EXTERNAL_DOWNLOADER, EXTERNAL_DOWNLOADER_ACTION_BUTTON));
+ "com.deniscerri.ytdl" /* YTDLnis */, parentsAny(EXTERNAL_DOWNLOADER, EXTERNAL_DOWNLOADER_ACTION_BUTTON));
// Comments
public static final BooleanSetting HIDE_COMMENTS_AI_CHAT_SUMMARY = new BooleanSetting("revanced_hide_comments_ai_chat_summary", FALSE);
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/CustomVideoSpeedListPreference.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/CustomVideoSpeedListPreference.java
index c6e98fd4d..cefa7774d 100644
--- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/CustomVideoSpeedListPreference.java
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/CustomVideoSpeedListPreference.java
@@ -16,10 +16,8 @@ import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings({"unused", "deprecation"})
public final class CustomVideoSpeedListPreference extends CustomDialogListPreference {
- /**
- * Initialize a settings preference list with the available playback speeds.
- */
- private void initializeEntryValues() {
+ {
+ // Initialize a settings preference list with the available playback speeds.
float[] customPlaybackSpeeds = CustomPlaybackSpeedPatch.customPlaybackSpeeds;
final int numberOfEntries = customPlaybackSpeeds.length + 1;
String[] preferenceListEntries = new String[numberOfEntries];
@@ -41,10 +39,6 @@ public final class CustomVideoSpeedListPreference extends CustomDialogListPrefer
setEntryValues(preferenceListEntryValues);
}
- {
- initializeEntryValues();
- }
-
public CustomVideoSpeedListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPreference.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPreference.java
new file mode 100644
index 000000000..0165e1376
--- /dev/null
+++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPreference.java
@@ -0,0 +1,444 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.StringRef.sf;
+import static app.revanced.extension.shared.StringRef.str;
+import static app.revanced.extension.shared.Utils.dipToPixels;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.Pair;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+
+import app.revanced.extension.shared.Logger;
+import app.revanced.extension.shared.Utils;
+import app.revanced.extension.shared.settings.preference.CustomDialogListPreference;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * A custom ListPreference for selecting an external downloader package with checkmarks and EditText for custom package names.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class ExternalDownloaderPreference extends CustomDialogListPreference {
+
+ /**
+ * Enum representing supported external downloaders with their display names, package names, and download URLs.
+ */
+ private enum Downloader {
+ YTDLNIS("YTDLnis",
+ "com.deniscerri.ytdl",
+ "https://ytdlnis.org",
+ true),
+ SEAL("Seal",
+ "com.junkfood.seal",
+ "https://github.com/JunkFood02/Seal/releases/latest",
+ true),
+ GRAYJAY("Grayjay",
+ "com.futo.platformplayer",
+ "https://grayjay.app"),
+ LIBRETUBE("LibreTube",
+ "com.github.libretube",
+ "https://libretube.dev"),
+ NEWPIPE("NewPipe",
+ "org.schabi.newpipe",
+ "https://newpipe.net"),
+ PIPEPIPE("PipePipe",
+ "InfinityLoop1309.NewPipeEnhanced",
+ "https://pipepipe.dev"),
+ TUBULAR("Tubular",
+ "org.polymorphicshade.tubular",
+ "https://github.com/polymorphicshade/Tubular/releases/latest"),
+ OTHER(sf("revanced_external_downloader_other_item").toString(),
+ null,
+ null,
+ true);
+
+ private static final Map PACKAGE_TO_ENUM = new HashMap<>();
+
+ static {
+ for (Downloader downloader : values()) {
+ String packageName = downloader.packageName;
+ if (packageName != null) {
+ PACKAGE_TO_ENUM.put(packageName, downloader);
+ }
+ }
+ }
+
+ /**
+ * Finds a Downloader by its package name. This method can never return {@link #OTHER}.
+ * @return The Downloader enum or null if not found.
+ */
+ @Nullable
+ public static Downloader findByPackageName(String packageName) {
+ return PACKAGE_TO_ENUM.get(Objects.requireNonNull(packageName));
+ }
+
+ public final String name;
+ @Nullable
+ public final String packageName;
+ @Nullable
+ public final String downloadUrl;
+ /**
+ * If a downloader app should be shown in the preference settings
+ * if the app is not currently installed.
+ */
+ public final boolean isPreferred;
+
+ Downloader(String name, String packageName, String downloadUrl) {
+ this(name, packageName, downloadUrl, false);
+ }
+
+ Downloader(String name, @Nullable String packageName, @Nullable String downloadUrl, boolean isPreferred) {
+ this.name = name;
+ this.packageName = packageName;
+ this.downloadUrl = downloadUrl;
+ this.isPreferred = isPreferred;
+ }
+
+ public boolean isInstalled() {
+ return packageName != null && isAppInstalledAndEnabled(packageName);
+ }
+ }
+
+ private static boolean isAppInstalledAndEnabled(String packageName) {
+ try {
+ if (Utils.getContext().getPackageManager().getApplicationInfo(packageName, 0).enabled) {
+ Logger.printDebug(() -> "App installed: " + packageName);
+ return true;
+ }
+ } catch (PackageManager.NameNotFoundException error) {
+ Logger.printDebug(() -> "App not installed: " + packageName);
+ }
+ return false;
+ }
+
+ private EditText editText;
+ private CustomDialogListPreference.ListPreferenceArrayAdapter adapter;
+
+ public ExternalDownloaderPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public ExternalDownloaderPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public ExternalDownloaderPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ExternalDownloaderPreference(Context context) {
+ super(context);
+ }
+
+ private void updateEntries() {
+ List entries = new ArrayList<>();
+ List entryValues = new ArrayList<>();
+
+ for (Downloader downloader : Downloader.values()) {
+ if (downloader.isPreferred || downloader.isInstalled()) {
+ String packageName = downloader.packageName;
+
+ entries.add(downloader.name);
+ entryValues.add(packageName != null
+ ? packageName
+ : Downloader.OTHER.name);
+ }
+ }
+
+ setEntries(entries.toArray(new CharSequence[0]));
+ setEntryValues(entryValues.toArray(new CharSequence[0]));
+ }
+
+ /**
+ * Sets the summary for this ListPreference.
+ */
+ @Override
+ public void setSummary(CharSequence summary) {
+ // Ignore calls to set the summary.
+ // Summary is always the description of the category.
+ //
+ // This is required otherwise the ReVanced preference fragment
+ // sets all ListPreference summaries to show the current selection.
+ }
+
+ /**
+ * Shows a custom dialog with a ListView for predefined downloader packages and EditText for custom package input.
+ */
+ @Override
+ protected void showDialog(@Nullable Bundle state) {
+ // Must set entries before showing the dialog, to handle if
+ // an app is installed while the settings are open in the background.
+ updateEntries();
+
+ Context context = getContext();
+ String packageName = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME.get();
+
+ // Create the main layout for the dialog content.
+ LinearLayout contentLayout = new LinearLayout(context);
+ contentLayout.setOrientation(LinearLayout.VERTICAL);
+
+ // Create ListView for predefined downloader apps.
+ ListView listView = new ListView(context);
+ listView.setId(android.R.id.list);
+ listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
+
+ // Create custom adapter for the ListView.
+ final boolean usingCustomDownloader = Downloader.findByPackageName(packageName) == null;
+ adapter = new CustomDialogListPreference.ListPreferenceArrayAdapter(
+ context,
+ Utils.getResourceIdentifier("revanced_custom_list_item_checked", "layout"),
+ getEntries(),
+ getEntryValues(),
+ usingCustomDownloader
+ ? Downloader.OTHER.name
+ : packageName
+ );
+ listView.setAdapter(adapter);
+
+ Function updateListViewSelection = (updatedPackageName) -> {
+ String entryValueName = Downloader.findByPackageName(updatedPackageName) == null
+ ? Downloader.OTHER.name
+ : updatedPackageName;
+ CharSequence[] entryValues = getEntryValues();
+
+ for (int i = 0, length = entryValues.length; i < length; i++) {
+ String entryString = entryValues[i].toString();
+ if (entryString.equals(entryValueName)) {
+ listView.setItemChecked(i, true);
+ listView.setSelection(i);
+ adapter.setSelectedValue(entryString);
+ adapter.notifyDataSetChanged();
+ break;
+ }
+ }
+ return null;
+ };
+ updateListViewSelection.apply(packageName);
+
+ // Handle item click to select value.
+ listView.setOnItemClickListener((parent, view, position, id) -> {
+ String selectedValue = getEntryValues()[position].toString();
+ Downloader selectedApp = Downloader.findByPackageName(selectedValue);
+
+ if (selectedApp != null) {
+ editText.setText(selectedApp.packageName);
+ editText.setEnabled(false); // Disable editing for predefined options.
+ } else {
+ String savedPackageName = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME.get();
+ editText.setText(Downloader.findByPackageName(savedPackageName) == null
+ ? savedPackageName // If the user is clicking thru options then retain existing other app.
+ : ""
+ );
+ editText.setEnabled(true); // Enable editing for Custom.
+ editText.requestFocus();
+ }
+ editText.setSelection(editText.getText().length());
+ adapter.setSelectedValue(selectedValue);
+ adapter.notifyDataSetChanged();
+ });
+
+ // Add ListView to content layout with initial height.
+ LinearLayout.LayoutParams listViewParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ 0 // Initial height, will be updated.
+ );
+ listViewParams.bottomMargin = dipToPixels(16);
+ contentLayout.addView(listView, listViewParams);
+
+ // Add EditText for custom package name.
+ editText = new EditText(context);
+ editText.setText(packageName);
+ editText.setSelection(packageName.length());
+ editText.setHint(str("revanced_external_downloader_other_item_hint"));
+ editText.setSingleLine(true); // Restrict EditText to a single line.
+ editText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
+ // Set initial EditText state based on selected downloader.
+ editText.setEnabled(usingCustomDownloader);
+ editText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {}
+
+ @Override
+ public void afterTextChanged(Editable edit) {
+ String updatedPackageName = edit.toString().trim();
+ updateListViewSelection.apply(updatedPackageName);
+ }
+ });
+
+ ShapeDrawable editTextBackground = new ShapeDrawable(new RoundRectShape(
+ Utils.createCornerRadii(10), null, null));
+ editTextBackground.getPaint().setColor(Utils.getEditTextBackground());
+ final int dip8 = dipToPixels(8);
+ editText.setPadding(dip8, dip8, dip8, dip8);
+ editText.setBackground(editTextBackground);
+ editText.setClipToOutline(true);
+ contentLayout.addView(editText);
+
+ // Create the custom dialog.
+ Pair
Download button opens your external downloader
Download button opens the native in-app downloader
Downloader package name
- Package name of your installed external downloader app, such as NewPipe or Seal
+ Package name of your installed external downloader app
+ Enter the package name
+ Other
+ App not installed
%s is not installed. Please install it.
+ "Could not find installed app with package name: %s
+
+Verify the package name is correct and the app is installed"
+ The package name cannot be empty
Disable precise seeking gesture