diff --git a/extensions/shared/build.gradle.kts b/extensions/shared/build.gradle.kts
index 69641ab9e..d9511b8b8 100644
--- a/extensions/shared/build.gradle.kts
+++ b/extensions/shared/build.gradle.kts
@@ -26,6 +26,7 @@ android {
dependencies {
compileOnly(libs.annotation)
compileOnly(libs.preference)
+ implementation(libs.collections4)
implementation(libs.lang3)
compileOnly(project(":extensions:shared:stub"))
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt
index bd99fc453..5ef4a4212 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt
@@ -7,8 +7,10 @@ import app.revanced.extension.shared.requests.Route
import app.revanced.extension.shared.requests.Route.CompiledRoute
import app.revanced.extension.shared.settings.BaseSettings
import app.revanced.extension.shared.utils.Logger
+import app.revanced.extension.shared.utils.StringRef.str
import app.revanced.extension.shared.utils.Utils
import org.apache.commons.lang3.StringUtils
+import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
@@ -20,6 +22,38 @@ import java.util.TimeZone
@Suppress("deprecation")
object PlayerRoutes {
+ @JvmField
+ val CREATE_PLAYLIST: CompiledRoute = Route(
+ Route.Method.POST,
+ "playlist/create" +
+ "?prettyPrint=false" +
+ "&fields=playlistId"
+ ).compile()
+
+ @JvmField
+ val DELETE_PLAYLIST: CompiledRoute = Route(
+ Route.Method.POST,
+ "playlist/delete" +
+ "?prettyPrint=false"
+ ).compile()
+
+ @JvmField
+ val EDIT_PLAYLIST: CompiledRoute = Route(
+ Route.Method.POST,
+ "browse/edit_playlist" +
+ "?prettyPrint=false" +
+ "&fields=status," +
+ "playlistEditResults"
+ ).compile()
+
+ @JvmField
+ val GET_PLAYLISTS: CompiledRoute = Route(
+ Route.Method.POST,
+ "playlist/get_add_to_playlist" +
+ "?prettyPrint=false" +
+ "&fields=contents.addToPlaylistRenderer.playlists.playlistAddToOptionRenderer"
+ ).compile()
+
@JvmField
val GET_CATEGORY: CompiledRoute = Route(
Route.Method.POST,
@@ -28,6 +62,16 @@ object PlayerRoutes {
"&fields=microformat.playerMicroformatRenderer.category"
).compile()
+ @JvmField
+ val GET_SET_VIDEO_ID: CompiledRoute = Route(
+ Route.Method.POST,
+ "next" +
+ "?prettyPrint=false" +
+ "&fields=contents.singleColumnWatchNextResults." +
+ "playlist.playlist.contents.playlistPanelVideoRenderer." +
+ "playlistSetVideoId"
+ ).compile()
+
@JvmField
val GET_PLAYLIST_PAGE: CompiledRoute = Route(
Route.Method.POST,
@@ -172,6 +216,150 @@ object PlayerRoutes {
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
}
+ private fun androidInnerTubeBody(
+ clientType: YouTubeAppClient.ClientType = YouTubeAppClient.ClientType.ANDROID
+ ): JSONObject {
+ val innerTubeBody = JSONObject()
+
+ try {
+ val client = JSONObject()
+ client.put("deviceMake", clientType.deviceMake)
+ client.put("deviceModel", clientType.deviceModel)
+ client.put("clientName", clientType.clientName)
+ client.put("clientVersion", clientType.clientVersion)
+ client.put("osName", clientType.osName)
+ client.put("osVersion", clientType.osVersion)
+ client.put("androidSdkVersion", clientType.androidSdkVersion)
+ if (clientType.gmscoreVersionCode != null) {
+ client.put("gmscoreVersionCode", clientType.gmscoreVersionCode)
+ }
+ client.put(
+ "hl",
+ LOCALE_LANGUAGE
+ )
+ client.put("gl", LOCALE_COUNTRY)
+ client.put("timeZone", TIME_ZONE_ID)
+ client.put("utcOffsetMinutes", "$UTC_OFFSET_MINUTES")
+
+ val context = JSONObject()
+ context.put("client", client)
+
+ innerTubeBody.put("context", context)
+ innerTubeBody.put("contentCheckOk", true)
+ innerTubeBody.put("racyCheckOk", true)
+ } catch (e: JSONException) {
+ Logger.printException({ "Failed to create android innerTubeBody" }, e)
+ }
+
+ return innerTubeBody
+ }
+
+ @JvmStatic
+ fun createPlaylistRequestBody(
+ videoId: String,
+ ): ByteArray {
+ val innerTubeBody = androidInnerTubeBody()
+
+ try {
+ innerTubeBody.put("params", "CAQ%3D")
+ // TODO: Implement an AlertDialog that allows changing the title of the playlist.
+ innerTubeBody.put("title", str("revanced_queue_manager_queue"))
+
+ val videoIds = JSONArray()
+ videoIds.put(0, videoId)
+ innerTubeBody.put("videoIds", videoIds)
+ } catch (e: JSONException) {
+ Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
+ }
+
+ return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
+ }
+
+ @JvmStatic
+ fun deletePlaylistRequestBody(
+ playlistId: String,
+ ): ByteArray {
+ val innerTubeBody = androidInnerTubeBody()
+
+ try {
+ innerTubeBody.put("playlistId", playlistId)
+ } catch (e: JSONException) {
+ Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
+ }
+
+ return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
+ }
+
+ @JvmStatic
+ fun editPlaylistRequestBody(
+ videoId: String,
+ playlistId: String,
+ setVideoId: String?,
+ ): ByteArray {
+ val innerTubeBody = androidInnerTubeBody()
+
+ try {
+ innerTubeBody.put("playlistId", playlistId)
+
+ val actionsObject = JSONObject()
+ if (setVideoId != null && setVideoId.isNotEmpty()) {
+ actionsObject.put("action", "ACTION_REMOVE_VIDEO")
+ actionsObject.put("setVideoId", setVideoId)
+ } else {
+ actionsObject.put("action", "ACTION_ADD_VIDEO")
+ actionsObject.put("addedVideoId", videoId)
+ }
+
+ val actionsArray = JSONArray()
+ actionsArray.put(0, actionsObject)
+ innerTubeBody.put("actions", actionsArray)
+ } catch (e: JSONException) {
+ Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
+ }
+
+ return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
+ }
+
+ @JvmStatic
+ fun getPlaylistsRequestBody(
+ playlistId: String,
+ ): ByteArray {
+ val innerTubeBody = androidInnerTubeBody()
+
+ try {
+ innerTubeBody.put("playlistId", playlistId)
+ innerTubeBody.put("excludeWatchLater", false)
+ } catch (e: JSONException) {
+ Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
+ }
+
+ return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
+ }
+
+ @JvmStatic
+ fun savePlaylistRequestBody(
+ playlistId: String,
+ libraryId: String,
+ ): ByteArray {
+ val innerTubeBody = androidInnerTubeBody()
+
+ try {
+ innerTubeBody.put("playlistId", playlistId)
+
+ val actionsObject = JSONObject()
+ actionsObject.put("action", "ACTION_ADD_PLAYLIST")
+ actionsObject.put("addedFullListId", libraryId)
+
+ val actionsArray = JSONArray()
+ actionsArray.put(0, actionsObject)
+ innerTubeBody.put("actions", actionsArray)
+ } catch (e: JSONException) {
+ Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
+ }
+
+ return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
+ }
+
@JvmStatic
fun getPlayerResponseConnectionFromRoute(
route: CompiledRoute,
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java
index 0c1607561..ba818a3c8 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java
@@ -1,18 +1,34 @@
package app.revanced.extension.youtube.patches.general;
-import app.revanced.extension.shared.settings.BooleanSetting;
+import static app.revanced.extension.youtube.utils.VideoUtils.launchPlaylistExternalDownloader;
+import static app.revanced.extension.youtube.utils.VideoUtils.launchVideoExternalDownloader;
+
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Map;
+
import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.patches.utils.PlaylistPatch;
import app.revanced.extension.youtube.settings.Settings;
-import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused")
-public final class DownloadActionsPatch extends VideoUtils {
+public final class DownloadActionsPatch {
- private static final BooleanSetting overrideVideoDownloadButton =
- Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON;
+ private static final boolean OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON =
+ Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON.get();
- private static final BooleanSetting overridePlaylistDownloadButton =
- Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON;
+ private static final boolean OVERRIDE_VIDEO_DOWNLOAD_BUTTON =
+ Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON.get();
+
+ private static final boolean OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER =
+ OVERRIDE_VIDEO_DOWNLOAD_BUTTON && Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER.get();
+
+ private static final String ELEMENTS_SENDER_VIEW =
+ "com.google.android.libraries.youtube.rendering.elements.sender_view";
/**
* Injection point.
@@ -23,17 +39,21 @@ public final class DownloadActionsPatch extends VideoUtils {
*
* Appears to always be called from the main thread.
*/
- public static boolean inAppVideoDownloadButtonOnClick(String videoId) {
+ public static boolean inAppVideoDownloadButtonOnClick(@Nullable Map map,Object offlineVideoEndpointOuterClass,
+ @Nullable String videoId) {
try {
- if (!overrideVideoDownloadButton.get()) {
- return false;
- }
- if (videoId == null || videoId.isEmpty()) {
- return false;
- }
- launchVideoExternalDownloader(videoId);
+ if (OVERRIDE_VIDEO_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(videoId)) {
+ if (OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER) {
+ if (map != null && map.get(ELEMENTS_SENDER_VIEW) instanceof View view) {
+ PlaylistPatch.setContext(view.getContext());
+ }
+ PlaylistPatch.prepareDialogBuilder(videoId);
+ } else {
+ launchVideoExternalDownloader(videoId);
+ }
- return true;
+ return true;
+ }
} catch (Exception ex) {
Logger.printException(() -> "inAppVideoDownloadButtonOnClick failure", ex);
}
@@ -49,15 +69,10 @@ public final class DownloadActionsPatch extends VideoUtils {
*/
public static String inAppPlaylistDownloadButtonOnClick(String playlistId) {
try {
- if (!overridePlaylistDownloadButton.get()) {
- return playlistId;
+ if (OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(playlistId)) {
+ launchPlaylistExternalDownloader(playlistId);
+ return "";
}
- if (playlistId == null || playlistId.isEmpty()) {
- return playlistId;
- }
- launchPlaylistExternalDownloader(playlistId);
-
- return "";
} catch (Exception ex) {
Logger.printException(() -> "inAppPlaylistDownloadButtonOnClick failure", ex);
}
@@ -73,15 +88,10 @@ public final class DownloadActionsPatch extends VideoUtils {
*/
public static boolean inAppPlaylistDownloadMenuOnClick(String playlistId) {
try {
- if (!overridePlaylistDownloadButton.get()) {
- return false;
+ if (OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(playlistId)) {
+ launchPlaylistExternalDownloader(playlistId);
+ return true;
}
- if (playlistId == null || playlistId.isEmpty()) {
- return false;
- }
- launchPlaylistExternalDownloader(playlistId);
-
- return true;
} catch (Exception ex) {
Logger.printException(() -> "inAppPlaylistDownloadMenuOnClick failure", ex);
}
@@ -92,7 +102,7 @@ public final class DownloadActionsPatch extends VideoUtils {
* Injection point.
*/
public static boolean overridePlaylistDownloadButtonVisibility() {
- return overridePlaylistDownloadButton.get();
+ return OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON;
}
}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java
index e6a572af6..450ee50f9 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java
@@ -6,7 +6,9 @@ import android.view.ViewGroup;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.patches.utils.PlaylistPatch;
import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.VideoInformation;
import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused")
@@ -19,7 +21,14 @@ public class ExternalDownload extends BottomControlButton {
bottomControlsViewGroup,
"external_download_button",
Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER,
- view -> VideoUtils.launchVideoExternalDownloader(),
+ view -> {
+ if (Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER_QUEUE_MANAGER.get()) {
+ PlaylistPatch.setContext(view.getContext());
+ PlaylistPatch.prepareDialogBuilder(VideoInformation.getVideoId());
+ } else {
+ VideoUtils.launchVideoExternalDownloader();
+ }
+ },
null
);
}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java
index 287ed6992..ef1bf3800 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java
@@ -546,6 +546,10 @@ public class PlayerPatch {
return Settings.HIDE_FILMSTRIP_OVERLAY.get();
}
+ public static boolean hideFilmstripOverlay(boolean original) {
+ return !Settings.HIDE_FILMSTRIP_OVERLAY.get() && original;
+ }
+
public static boolean hideInfoCard(boolean original) {
return !Settings.HIDE_INFO_CARDS.get() && original;
}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/CustomActionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/CustomActionsPatch.java
index 4bf010129..9b76a935c 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/CustomActionsPatch.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/CustomActionsPatch.java
@@ -1,25 +1,14 @@
package app.revanced.extension.youtube.patches.shorts;
import static app.revanced.extension.shared.utils.ResourceUtils.getString;
-import static app.revanced.extension.shared.utils.Utils.dpToPx;
import static app.revanced.extension.youtube.patches.components.ShortsCustomActionsFilter.isShortsFlyoutMenuVisible;
import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
-import android.app.AlertDialog;
import android.content.Context;
-import android.graphics.ColorFilter;
-import android.graphics.PorterDuff;
-import android.graphics.PorterDuffColorFilter;
-import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
-import android.graphics.drawable.GradientDrawable;
-import android.graphics.drawable.StateListDrawable;
import android.support.v7.widget.RecyclerView;
-import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
-import android.view.Window;
-import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
@@ -42,7 +31,7 @@ import app.revanced.extension.shared.utils.Utils;
import app.revanced.extension.youtube.patches.components.ShortsCustomActionsFilter;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.ShortsPlayerState;
-import app.revanced.extension.youtube.utils.ThemeUtils;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused")
@@ -90,105 +79,28 @@ public final class CustomActionsPatch {
}), 0);
}
- private static void showMoreButtonDialog(Context context) {
- ScrollView scrollView = new ScrollView(context);
- LinearLayout container = new LinearLayout(context);
+ private static void showMoreButtonDialog(Context mContext) {
+ ScrollView mScrollView = new ScrollView(mContext);
+ LinearLayout mLinearLayout = new LinearLayout(mContext);
+ mLinearLayout.setOrientation(LinearLayout.VERTICAL);
+ mLinearLayout.setPadding(0, 0, 0, 0);
- container.setOrientation(LinearLayout.VERTICAL);
- container.setPadding(0, 0, 0, 0);
-
- Map toolbarMap = new LinkedHashMap<>(arrSize);
+ Map actionsMap = new LinkedHashMap<>(arrSize);
for (CustomAction customAction : CustomAction.values()) {
if (customAction.settings.get()) {
String title = customAction.getLabel();
int iconId = customAction.getDrawableId();
Runnable action = customAction.getOnClickAction();
- LinearLayout itemLayout = createItemLayout(context, title, iconId);
- toolbarMap.putIfAbsent(itemLayout, action);
- container.addView(itemLayout);
+ LinearLayout itemLayout = ExtendedUtils.createItemLayout(mContext, title, iconId);
+ actionsMap.putIfAbsent(itemLayout, action);
+ mLinearLayout.addView(itemLayout);
}
}
- scrollView.addView(container);
+ mScrollView.addView(mLinearLayout);
- AlertDialog.Builder builder = new AlertDialog.Builder(context);
- builder.setView(scrollView);
-
- AlertDialog dialog = builder.create();
- dialog.show();
-
- toolbarMap.forEach((view, action) ->
- view.setOnClickListener(v -> {
- action.run();
- dialog.dismiss();
- })
- );
- toolbarMap.clear();
-
- Window window = dialog.getWindow();
- if (window == null) {
- return;
- }
-
- // round corners
- GradientDrawable dialogBackground = new GradientDrawable();
- dialogBackground.setCornerRadius(32);
- window.setBackgroundDrawable(dialogBackground);
-
- // fit screen width
- int dialogWidth = (int) (context.getResources().getDisplayMetrics().widthPixels * 0.95);
- window.setLayout(dialogWidth, ViewGroup.LayoutParams.WRAP_CONTENT);
-
- // move dialog to bottom
- WindowManager.LayoutParams layoutParams = window.getAttributes();
- layoutParams.gravity = Gravity.BOTTOM;
-
- // adjust the vertical offset
- layoutParams.y = dpToPx(5);
-
- window.setAttributes(layoutParams);
- }
-
- private static LinearLayout createItemLayout(Context context, String title, int iconId) {
- // Item Layout
- LinearLayout itemLayout = new LinearLayout(context);
- itemLayout.setOrientation(LinearLayout.HORIZONTAL);
- itemLayout.setPadding(dpToPx(16), dpToPx(12), dpToPx(16), dpToPx(12));
- itemLayout.setGravity(Gravity.CENTER_VERTICAL);
- itemLayout.setClickable(true);
- itemLayout.setFocusable(true);
-
- // Create a StateListDrawable for the background
- StateListDrawable background = new StateListDrawable();
- ColorDrawable pressedDrawable = new ColorDrawable(ThemeUtils.getPressedElementColor());
- ColorDrawable defaultDrawable = new ColorDrawable(ThemeUtils.getBackgroundColor());
- background.addState(new int[]{android.R.attr.state_pressed}, pressedDrawable);
- background.addState(new int[]{}, defaultDrawable);
- itemLayout.setBackground(background);
-
- // Icon
- ColorFilter cf = new PorterDuffColorFilter(ThemeUtils.getForegroundColor(), PorterDuff.Mode.SRC_ATOP);
- ImageView iconView = new ImageView(context);
- iconView.setImageResource(iconId);
- iconView.setColorFilter(cf);
- LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(dpToPx(24), dpToPx(24));
- iconParams.setMarginEnd(dpToPx(16));
- iconView.setLayoutParams(iconParams);
- itemLayout.addView(iconView);
-
- // Text container
- LinearLayout textContainer = new LinearLayout(context);
- textContainer.setOrientation(LinearLayout.VERTICAL);
- TextView titleView = new TextView(context);
- titleView.setText(title);
- titleView.setTextSize(16);
- titleView.setTextColor(ThemeUtils.getForegroundColor());
- textContainer.addView(titleView);
-
- itemLayout.addView(textContainer);
-
- return itemLayout;
+ ExtendedUtils.showBottomSheetDialog(mContext, mScrollView, actionsMap);
}
private static boolean isMoreButton(String enumString) {
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlaylistPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlaylistPatch.java
new file mode 100644
index 000000000..d33a55441
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlaylistPatch.java
@@ -0,0 +1,495 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import android.content.Context;
+import android.view.KeyEvent;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+
+import org.apache.commons.collections4.BidiMap;
+import org.apache.commons.collections4.bidimap.DualHashBidiMap;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.ResourceUtils;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.utils.requests.CreatePlaylistRequest;
+import app.revanced.extension.youtube.patches.utils.requests.DeletePlaylistRequest;
+import app.revanced.extension.youtube.patches.utils.requests.EditPlaylistRequest;
+import app.revanced.extension.youtube.patches.utils.requests.GetPlaylistsRequest;
+import app.revanced.extension.youtube.patches.utils.requests.SavePlaylistRequest;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+import app.revanced.extension.youtube.utils.VideoUtils;
+import kotlin.Pair;
+
+// TODO: Implement sync queue and clean up code.
+@SuppressWarnings({"unused", "StaticFieldLeak"})
+public class PlaylistPatch extends VideoUtils {
+ private static final String AUTHORIZATION_HEADER = "Authorization";
+ private static final String[] REQUEST_HEADER_KEYS = {
+ AUTHORIZATION_HEADER,
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ };
+ private static final boolean QUEUE_MANAGER =
+ Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER_QUEUE_MANAGER.get()
+ || Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER.get();
+
+ private static Context mContext;
+ private static volatile String authorization = "";
+ private static volatile boolean isIncognito = false;
+ private static volatile Map requestHeader;
+ private static volatile String playlistId = "";
+ private static volatile String videoId = "";
+
+ private static final String checkFailedAuth =
+ ResourceUtils.getString("revanced_queue_manager_check_failed_auth");
+ private static final String checkFailedPlaylistId =
+ ResourceUtils.getString("revanced_queue_manager_check_failed_playlist_id");
+ private static final String checkFailedQueue =
+ ResourceUtils.getString("revanced_queue_manager_check_failed_queue");
+ private static final String checkFailedVideoId =
+ ResourceUtils.getString("revanced_queue_manager_check_failed_video_id");
+ private static final String checkFailedGeneric =
+ ResourceUtils.getString("revanced_queue_manager_check_failed_generic");
+
+ private static final String fetchFailedAdd =
+ ResourceUtils.getString("revanced_queue_manager_fetch_failed_add");
+ private static final String fetchFailedCreate =
+ ResourceUtils.getString("revanced_queue_manager_fetch_failed_create");
+ private static final String fetchFailedDelete =
+ ResourceUtils.getString("revanced_queue_manager_fetch_failed_delete");
+ private static final String fetchFailedRemove =
+ ResourceUtils.getString("revanced_queue_manager_fetch_failed_remove");
+ private static final String fetchFailedSave =
+ ResourceUtils.getString("revanced_queue_manager_fetch_failed_save");
+
+ private static final String fetchSucceededAdd =
+ ResourceUtils.getString("revanced_queue_manager_fetch_succeeded_add");
+ private static final String fetchSucceededCreate =
+ ResourceUtils.getString("revanced_queue_manager_fetch_succeeded_create");
+ private static final String fetchSucceededDelete =
+ ResourceUtils.getString("revanced_queue_manager_fetch_succeeded_delete");
+ private static final String fetchSucceededRemove =
+ ResourceUtils.getString("revanced_queue_manager_fetch_succeeded_remove");
+ private static final String fetchSucceededSave =
+ ResourceUtils.getString("revanced_queue_manager_fetch_succeeded_save");
+
+ @GuardedBy("itself")
+ private static final BidiMap lastVideoIds = new DualHashBidiMap<>();
+
+ /**
+ * Injection point.
+ */
+ public static boolean onKeyLongPress(int keyCode) {
+ if (!QUEUE_MANAGER || keyCode != KeyEvent.KEYCODE_BACK) {
+ return false;
+ }
+ if (mContext == null) {
+ handleCheckError(checkFailedQueue);
+ return false;
+ }
+ prepareDialogBuilder("");
+ return true;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void removeFromQueue(@Nullable String setVideoId) {
+ if (StringUtils.isNotEmpty(setVideoId)) {
+ synchronized (lastVideoIds) {
+ String videoId = lastVideoIds.inverseBidiMap().get(setVideoId);
+ if (videoId != null) {
+ lastVideoIds.remove(videoId, setVideoId);
+ EditPlaylistRequest.clearVideoId(videoId);
+ }
+ }
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setIncognitoStatus(boolean incognito) {
+ if (QUEUE_MANAGER) {
+ isIncognito = incognito;
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setRequestHeaders(String url, Map requestHeaders) {
+ if (QUEUE_MANAGER) {
+ try {
+ // Save requestHeaders whenever an account is switched.
+ String auth = requestHeaders.get(AUTHORIZATION_HEADER);
+ if (auth == null || authorization.equals(auth)) {
+ return;
+ }
+ for (String key : REQUEST_HEADER_KEYS) {
+ if (requestHeaders.get(key) == null) {
+ return;
+ }
+ }
+ authorization = auth;
+ requestHeader = requestHeaders;
+ } catch (Exception ex) {
+ Logger.printException(() -> "setRequestHeaders failure", ex);
+ }
+ }
+ }
+
+ /**
+ * Invoked by extension.
+ */
+ public static void setContext(Context context) {
+ mContext = context;
+ }
+
+ /**
+ * Invoked by extension.
+ */
+ public static void prepareDialogBuilder(@NonNull String currentVideoId) {
+ if (authorization.isEmpty() || isIncognito) {
+ handleCheckError(checkFailedAuth);
+ return;
+ }
+ if (currentVideoId.isEmpty()) {
+ buildBottomSheetDialog(QueueManager.noVideoIdQueueEntries);
+ } else {
+ videoId = currentVideoId;
+ synchronized (lastVideoIds) {
+ QueueManager[] customActionsEntries = playlistId.isEmpty() || lastVideoIds.get(currentVideoId) == null
+ ? QueueManager.addToQueueEntries
+ : QueueManager.removeFromQueueEntries;
+
+ buildBottomSheetDialog(customActionsEntries);
+ }
+ }
+ }
+
+ private static void buildBottomSheetDialog(QueueManager[] queueManagerEntries) {
+ ScrollView mScrollView = new ScrollView(mContext);
+ LinearLayout mLinearLayout = new LinearLayout(mContext);
+ mLinearLayout.setOrientation(LinearLayout.VERTICAL);
+ mLinearLayout.setPadding(0, 0, 0, 0);
+
+ Map actionsMap = new LinkedHashMap<>(queueManagerEntries.length);
+
+ for (QueueManager queueManager : queueManagerEntries) {
+ String title = queueManager.label;
+ int iconId = queueManager.drawableId;
+ Runnable action = queueManager.onClickAction;
+ LinearLayout itemLayout = ExtendedUtils.createItemLayout(mContext, title, iconId);
+ actionsMap.putIfAbsent(itemLayout, action);
+ mLinearLayout.addView(itemLayout);
+ }
+
+ mScrollView.addView(mLinearLayout);
+
+ ExtendedUtils.showBottomSheetDialog(mContext, mScrollView, actionsMap);
+ }
+
+ private static void fetchQueue(boolean remove, boolean openPlaylist, boolean openVideo) {
+ try {
+ String currentPlaylistId = playlistId;
+ String currentVideoId = videoId;
+ synchronized (lastVideoIds) {
+ if (currentPlaylistId.isEmpty()) { // Queue is empty, create new playlist.
+ CreatePlaylistRequest.fetchRequestIfNeeded(currentVideoId, requestHeader);
+ runOnMainThreadDelayed(() -> {
+ CreatePlaylistRequest request = CreatePlaylistRequest.getRequestForVideoId(currentVideoId);
+ if (request != null) {
+ Pair playlistIds = request.getPlaylistId();
+ if (playlistIds != null) {
+ String createdPlaylistId = playlistIds.getFirst();
+ String setVideoId = playlistIds.getSecond();
+ if (createdPlaylistId != null && setVideoId != null) {
+ playlistId = createdPlaylistId;
+ lastVideoIds.putIfAbsent(currentVideoId, setVideoId);
+ showToast(fetchSucceededCreate);
+ Logger.printDebug(() -> "Queue successfully created, playlistId: " + createdPlaylistId + ", setVideoId: " + setVideoId);
+ if (openPlaylist) {
+ openQueue(currentVideoId, openVideo);
+ }
+ return;
+ }
+ }
+ }
+ showToast(fetchFailedCreate);
+ }, 1000);
+ } else { // Queue is not empty, add or remove video.
+ String setVideoId = lastVideoIds.get(currentVideoId);
+ EditPlaylistRequest.fetchRequestIfNeeded(currentVideoId, currentPlaylistId, setVideoId, requestHeader);
+
+ runOnMainThreadDelayed(() -> {
+ EditPlaylistRequest request = EditPlaylistRequest.getRequestForVideoId(currentVideoId);
+ if (request != null) {
+ String fetchedSetVideoId = request.getResult();
+ Logger.printDebug(() -> "fetchedSetVideoId: " + fetchedSetVideoId);
+ if (remove) { // Remove from queue.
+ if (StringUtils.isEmpty(fetchedSetVideoId)) {
+ lastVideoIds.remove(currentVideoId, setVideoId);
+ showToast(fetchSucceededRemove);
+ if (openPlaylist) {
+ openQueue(currentVideoId, openVideo);
+ }
+ return;
+ }
+ showToast(fetchFailedRemove);
+ } else { // Add to queue.
+ if (StringUtils.isNotEmpty(fetchedSetVideoId)) {
+ lastVideoIds.putIfAbsent(currentVideoId, fetchedSetVideoId);
+ showToast(fetchSucceededAdd);
+ Logger.printDebug(() -> "Video successfully added, setVideoId: " + fetchedSetVideoId);
+ if (openPlaylist) {
+ openQueue(currentVideoId, openVideo);
+ }
+ return;
+ }
+ showToast(fetchFailedAdd);
+ }
+ }
+ }, 1000);
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "fetchQueue failure", ex);
+ }
+ }
+
+ private static void saveToPlaylist() {
+ String currentPlaylistId = playlistId;
+ if (currentPlaylistId.isEmpty()) {
+ handleCheckError(checkFailedQueue);
+ return;
+ }
+ try {
+ GetPlaylistsRequest.fetchRequestIfNeeded(currentPlaylistId, requestHeader);
+ runOnMainThreadDelayed(() -> {
+ GetPlaylistsRequest request = GetPlaylistsRequest.getRequestForPlaylistId(currentPlaylistId);
+ if (request != null) {
+ Pair[] playlists = request.getPlaylists();
+ if (playlists != null) {
+ ScrollView mScrollView = new ScrollView(mContext);
+ LinearLayout mLinearLayout = new LinearLayout(mContext);
+ mLinearLayout.setOrientation(LinearLayout.VERTICAL);
+ mLinearLayout.setPadding(0, 0, 0, 0);
+
+ Map actionsMap = new LinkedHashMap<>(playlists.length);
+
+ int libraryIconId = QueueManager.SAVE_QUEUE.drawableId;
+
+ for (Pair playlist : playlists) {
+ String playlistId = playlist.getFirst();
+ String title = playlist.getSecond();
+ Runnable action = () -> saveToPlaylist(playlistId, title);
+ LinearLayout itemLayout = ExtendedUtils.createItemLayout(mContext, title, libraryIconId);
+ actionsMap.putIfAbsent(itemLayout, action);
+ mLinearLayout.addView(itemLayout);
+ }
+
+ mScrollView.addView(mLinearLayout);
+
+ ExtendedUtils.showBottomSheetDialog(mContext, mScrollView, actionsMap);
+ GetPlaylistsRequest.clear();
+ }
+ }
+ }, 1000);
+ } catch (Exception ex) {
+ Logger.printException(() -> "saveToPlaylist failure", ex);
+ }
+ }
+
+ private static void saveToPlaylist(@Nullable String libraryId, @Nullable String libraryTitle) {
+ try {
+ if (StringUtils.isEmpty(libraryId)) {
+ handleCheckError(checkFailedPlaylistId);
+ return;
+ }
+ SavePlaylistRequest.fetchRequestIfNeeded(playlistId, libraryId, requestHeader);
+
+ runOnMainThreadDelayed(() -> {
+ SavePlaylistRequest request = SavePlaylistRequest.getRequestForLibraryId(libraryId);
+ if (request != null) {
+ Boolean result = request.getResult();
+ if (BooleanUtils.isTrue(result)) {
+ showToast(String.format(fetchSucceededSave, libraryTitle));
+ SavePlaylistRequest.clear();
+ return;
+ }
+ showToast(fetchFailedSave);
+ }
+ }, 1000);
+ } catch (Exception ex) {
+ Logger.printException(() -> "saveToPlaylist failure", ex);
+ }
+ }
+
+ private static void removeQueue() {
+ String currentPlaylistId = playlistId;
+ if (currentPlaylistId.isEmpty()) {
+ handleCheckError(checkFailedQueue);
+ return;
+ }
+ try {
+ DeletePlaylistRequest.fetchRequestIfNeeded(currentPlaylistId, requestHeader);
+ runOnMainThreadDelayed(() -> {
+ DeletePlaylistRequest request = DeletePlaylistRequest.getRequestForPlaylistId(currentPlaylistId);
+ if (request != null) {
+ Boolean result = request.getResult();
+ if (BooleanUtils.isTrue(result)) {
+ playlistId = "";
+ synchronized (lastVideoIds) {
+ lastVideoIds.clear();
+ }
+ CreatePlaylistRequest.clear();
+ DeletePlaylistRequest.clear();
+ EditPlaylistRequest.clear();
+ GetPlaylistsRequest.clear();
+ SavePlaylistRequest.clear();
+ showToast(fetchSucceededDelete);
+ return;
+ }
+ }
+ showToast(fetchFailedDelete);
+ }, 1000);
+ } catch (Exception ex) {
+ Logger.printException(() -> "removeQueue failure", ex);
+ }
+ }
+
+ private static void downloadVideo() {
+ String currentVideoId = videoId;
+ launchVideoExternalDownloader(currentVideoId);
+ }
+
+ private static void openQueue() {
+ openQueue("", false);
+ }
+
+ private static void openQueue(String currentVideoId, boolean openVideo) {
+ String currentPlaylistId = playlistId;
+ if (currentPlaylistId.isEmpty()) {
+ handleCheckError(checkFailedQueue);
+ return;
+ }
+ if (openVideo) {
+ if (StringUtils.isEmpty(currentVideoId)) {
+ handleCheckError(checkFailedVideoId);
+ return;
+ }
+ // Open a video from a playlist
+ openPlaylist(currentPlaylistId, currentVideoId);
+ } else {
+ // Open a playlist
+ openPlaylist(currentPlaylistId);
+ }
+ }
+
+ private static void handleCheckError(String reason) {
+ showToast(String.format(checkFailedGeneric, reason));
+ }
+
+ private static void showToast(String reason) {
+ Utils.showToastShort(reason);
+ }
+
+ private enum QueueManager {
+ ADD_TO_QUEUE(
+ "revanced_queue_manager_add_to_queue",
+ "yt_outline_list_add_black_24",
+ () -> fetchQueue(false, false, false)
+ ),
+ ADD_TO_QUEUE_AND_OPEN_QUEUE(
+ "revanced_queue_manager_add_to_queue_and_open_queue",
+ "yt_outline_list_add_black_24",
+ () -> fetchQueue(false, true, false)
+ ),
+ ADD_TO_QUEUE_AND_PLAY_VIDEO(
+ "revanced_queue_manager_add_to_queue_and_play_video",
+ "yt_outline_list_play_arrow_black_24",
+ () -> fetchQueue(false, true, true)
+ ),
+ REMOVE_FROM_QUEUE(
+ "revanced_queue_manager_remove_from_queue",
+ "yt_outline_trash_can_black_24",
+ () -> fetchQueue(true, false, false)
+ ),
+ REMOVE_FROM_QUEUE_AND_OPEN_QUEUE(
+ "revanced_queue_manager_remove_from_queue_and_open_queue",
+ "yt_outline_trash_can_black_24",
+ () -> fetchQueue(true, true, false)
+ ),
+ OPEN_QUEUE(
+ "revanced_queue_manager_open_queue",
+ "yt_outline_list_view_black_24",
+ PlaylistPatch::openQueue
+ ),
+ // For some reason, the 'playlist/delete' endpoint is unavailable.
+ REMOVE_QUEUE(
+ "revanced_queue_manager_remove_queue",
+ "yt_outline_slash_circle_left_black_24",
+ PlaylistPatch::removeQueue
+ ),
+ SAVE_QUEUE(
+ "revanced_queue_manager_save_queue",
+ "yt_outline_bookmark_black_24",
+ PlaylistPatch::saveToPlaylist
+ ),
+ EXTERNAL_DOWNLOADER(
+ "revanced_queue_manager_external_downloader",
+ "yt_outline_download_black_24",
+ PlaylistPatch::downloadVideo
+ );
+
+ public final int drawableId;
+
+ @NonNull
+ public final String label;
+
+ @NonNull
+ public final Runnable onClickAction;
+
+ QueueManager(@NonNull String label, @NonNull String icon, @NonNull Runnable onClickAction) {
+ this.drawableId = ResourceUtils.getDrawableIdentifier(icon);
+ this.label = ResourceUtils.getString(label);
+ this.onClickAction = onClickAction;
+ }
+
+ public static final QueueManager[] addToQueueEntries = {
+ ADD_TO_QUEUE,
+ ADD_TO_QUEUE_AND_OPEN_QUEUE,
+ ADD_TO_QUEUE_AND_PLAY_VIDEO,
+ OPEN_QUEUE,
+ //REMOVE_QUEUE,
+ EXTERNAL_DOWNLOADER,
+ SAVE_QUEUE,
+ };
+
+ public static final QueueManager[] removeFromQueueEntries = {
+ REMOVE_FROM_QUEUE,
+ REMOVE_FROM_QUEUE_AND_OPEN_QUEUE,
+ OPEN_QUEUE,
+ //REMOVE_QUEUE,
+ EXTERNAL_DOWNLOADER,
+ SAVE_QUEUE,
+ };
+
+ public static final QueueManager[] noVideoIdQueueEntries = {
+ OPEN_QUEUE,
+ //REMOVE_QUEUE,
+ SAVE_QUEUE,
+ };
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/CreatePlaylistRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/CreatePlaylistRequest.kt
new file mode 100644
index 000000000..bf72d8966
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/CreatePlaylistRequest.kt
@@ -0,0 +1,275 @@
+package app.revanced.extension.youtube.patches.utils.requests
+
+import androidx.annotation.GuardedBy
+import app.revanced.extension.shared.patches.client.YouTubeAppClient
+import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes
+import app.revanced.extension.shared.requests.Requester
+import app.revanced.extension.shared.utils.Logger
+import app.revanced.extension.shared.utils.Utils
+import app.revanced.extension.youtube.patches.utils.requests.CreatePlaylistRequest.Companion.HTTP_TIMEOUT_MILLISECONDS
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.util.Collections
+import java.util.Objects
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+class CreatePlaylistRequest private constructor(
+ private val videoId: String,
+ private val playerHeaders: Map,
+) {
+ private val future: Future> = Utils.submitOnBackgroundThread {
+ fetch(videoId, playerHeaders)
+ }
+
+ val playlistId: Pair?
+ get() {
+ try {
+ return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS]
+ } catch (ex: TimeoutException) {
+ Logger.printInfo(
+ { "getPlaylistId timed out" },
+ ex
+ )
+ } catch (ex: InterruptedException) {
+ Logger.printException(
+ { "getPlaylistId interrupted" },
+ ex
+ )
+ Thread.currentThread().interrupt() // Restore interrupt status flag.
+ } catch (ex: ExecutionException) {
+ Logger.printException(
+ { "getPlaylistId failure" },
+ ex
+ )
+ }
+
+ return null
+ }
+
+ companion object {
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private const val HTTP_TIMEOUT_MILLISECONDS = 10 * 1000
+
+ /**
+ * Any arbitrarily large value, but must be at least twice [HTTP_TIMEOUT_MILLISECONDS]
+ */
+ private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000
+
+ @GuardedBy("itself")
+ val cache: MutableMap = Collections.synchronizedMap(
+ object : LinkedHashMap(100) {
+ private val CACHE_LIMIT = 50
+
+ override fun removeEldestEntry(eldest: Map.Entry): Boolean {
+ return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
+ }
+ })
+
+ @JvmStatic
+ fun clear() {
+ synchronized(cache) {
+ cache.clear()
+ }
+ }
+
+ @JvmStatic
+ fun fetchRequestIfNeeded(videoId: String, playerHeaders: Map) {
+ Objects.requireNonNull(videoId)
+ synchronized(cache) {
+ if (!cache.containsKey(videoId)) {
+ cache[videoId] = CreatePlaylistRequest(videoId, playerHeaders)
+ }
+ }
+ }
+
+ @JvmStatic
+ fun getRequestForVideoId(videoId: String): CreatePlaylistRequest? {
+ synchronized(cache) {
+ return cache[videoId]
+ }
+ }
+
+ private fun handleConnectionError(toastMessage: String, ex: Exception?) {
+ Logger.printInfo({ toastMessage }, ex)
+ }
+
+ private val REQUEST_HEADER_KEYS = arrayOf(
+ "Authorization", // Available only to logged-in users.
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ )
+
+ private fun sendCreatePlaylistRequest(videoId: String, playerHeaders: Map): JSONObject? {
+ Objects.requireNonNull(videoId)
+
+ val startTime = System.currentTimeMillis()
+ // 'playlist/create' request does not require PoToken.
+ val clientType = YouTubeAppClient.ClientType.ANDROID
+ val clientTypeName = clientType.name
+ Logger.printDebug { "Fetching create playlist request for: $videoId, using client: $clientTypeName" }
+
+ try {
+ val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(
+ PlayerRoutes.CREATE_PLAYLIST,
+ clientType
+ )
+ connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS
+ connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS
+
+ for (key in REQUEST_HEADER_KEYS) {
+ var value = playerHeaders[key]
+ if (value != null) {
+ connection.setRequestProperty(key, value)
+ }
+ }
+
+ val requestBody =
+ PlayerRoutes.createPlaylistRequestBody(
+ videoId = videoId
+ )
+
+ connection.setFixedLengthStreamingMode(requestBody.size)
+ connection.outputStream.write(requestBody)
+
+ val responseCode = connection.responseCode
+ if (responseCode == 200) return Requester.parseJSONObject(connection)
+
+ handleConnectionError(
+ (clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.responseMessage),
+ null
+ )
+ } catch (ex: SocketTimeoutException) {
+ handleConnectionError("Connection timeout", ex)
+ } catch (ex: IOException) {
+ handleConnectionError("Network error", ex)
+ } catch (ex: Exception) {
+ Logger.printException({ "sendCreatePlaylistRequest failed" }, ex)
+ } finally {
+ Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
+ }
+
+ return null
+ }
+
+ private fun sendSetVideoIdRequest(videoId: String, playlistId: String, playerHeaders: Map): JSONObject? {
+ Objects.requireNonNull(playlistId)
+
+ val startTime = System.currentTimeMillis()
+ // 'playlist/create' request does not require PoToken.
+ val clientType = YouTubeAppClient.ClientType.ANDROID
+ val clientTypeName = clientType.name
+ Logger.printDebug { "Fetching set video id request for: $playlistId, using client: $clientTypeName" }
+
+ try {
+ val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(
+ PlayerRoutes.GET_SET_VIDEO_ID,
+ clientType
+ )
+ connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS
+ connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS
+
+ for (key in REQUEST_HEADER_KEYS) {
+ var value = playerHeaders[key]
+ if (value != null) {
+ connection.setRequestProperty(key, value)
+ }
+ }
+
+ val requestBody =
+ PlayerRoutes.createApplicationRequestBody(
+ clientType = clientType,
+ videoId = videoId,
+ playlistId = playlistId
+ )
+
+ connection.setFixedLengthStreamingMode(requestBody.size)
+ connection.outputStream.write(requestBody)
+
+ val responseCode = connection.responseCode
+ if (responseCode == 200) return Requester.parseJSONObject(connection)
+
+ handleConnectionError(
+ (clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.responseMessage),
+ null
+ )
+ } catch (ex: SocketTimeoutException) {
+ handleConnectionError("Connection timeout", ex)
+ } catch (ex: IOException) {
+ handleConnectionError("Network error", ex)
+ } catch (ex: Exception) {
+ Logger.printException({ "sendSetVideoIdRequest failed" }, ex)
+ } finally {
+ Logger.printDebug { "playlist: " + playlistId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
+ }
+
+ return null
+ }
+
+ private fun parseCreatePlaylistResponse(json: JSONObject): String? {
+ try {
+ return json.getString("playlistId")
+ } catch (e: JSONException) {
+ val jsonForMessage = json.toString()
+ Logger.printException(
+ { "Fetch failed while processing response data for response: $jsonForMessage" },
+ e
+ )
+ }
+
+ return null
+ }
+
+ private fun parseSetVideoIdResponse(json: JSONObject): String? {
+ try {
+ val secondaryContentsJsonObject =
+ json.getJSONObject("contents")
+ .getJSONObject("singleColumnWatchNextResults")
+ .getJSONObject("playlist")
+ .getJSONObject("playlist")
+ .getJSONArray("contents")
+ .get(0)
+
+ if (secondaryContentsJsonObject is JSONObject) {
+ return secondaryContentsJsonObject
+ .getJSONObject("playlistPanelVideoRenderer")
+ .getString("playlistSetVideoId")
+ }
+ } catch (e: JSONException) {
+ val jsonForMessage = json.toString()
+ Logger.printException(
+ { "Fetch failed while processing response data for response: $jsonForMessage" },
+ e
+ )
+ }
+
+ return null
+ }
+
+ private fun fetch(videoId: String, playerHeaders: Map): Pair? {
+ val createPlaylistJson = sendCreatePlaylistRequest(videoId, playerHeaders)
+ if (createPlaylistJson != null) {
+ val playlistId = parseCreatePlaylistResponse(createPlaylistJson)
+ if (playlistId != null) {
+ val setVideoIdJson = sendSetVideoIdRequest(videoId, playlistId, playerHeaders)
+ if (setVideoIdJson != null) {
+ val setVideoId = parseSetVideoIdResponse(setVideoIdJson)
+ if (setVideoId != null) {
+ return Pair(playlistId, setVideoId)
+ }
+ }
+ }
+ }
+
+ return null
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/DeletePlaylistRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/DeletePlaylistRequest.kt
new file mode 100644
index 000000000..7a0c8fc95
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/DeletePlaylistRequest.kt
@@ -0,0 +1,197 @@
+package app.revanced.extension.youtube.patches.utils.requests
+
+import androidx.annotation.GuardedBy
+import app.revanced.extension.shared.patches.client.YouTubeAppClient
+import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes
+import app.revanced.extension.shared.requests.Requester
+import app.revanced.extension.shared.utils.Logger
+import app.revanced.extension.shared.utils.Utils
+import app.revanced.extension.youtube.patches.utils.requests.DeletePlaylistRequest.Companion.HTTP_TIMEOUT_MILLISECONDS
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.util.Collections
+import java.util.Objects
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+class DeletePlaylistRequest private constructor(
+ private val playlistId: String,
+ private val playerHeaders: Map,
+) {
+ private val future: Future = Utils.submitOnBackgroundThread {
+ fetch(
+ playlistId,
+ playerHeaders,
+ )
+ }
+
+ val result: Boolean?
+ get() {
+ try {
+ return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS]
+ } catch (ex: TimeoutException) {
+ Logger.printInfo(
+ { "getResult timed out" },
+ ex
+ )
+ } catch (ex: InterruptedException) {
+ Logger.printException(
+ { "getResult interrupted" },
+ ex
+ )
+ Thread.currentThread().interrupt() // Restore interrupt status flag.
+ } catch (ex: ExecutionException) {
+ Logger.printException(
+ { "getResult failure" },
+ ex
+ )
+ }
+
+ return null
+ }
+
+ companion object {
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private const val HTTP_TIMEOUT_MILLISECONDS = 10 * 1000
+
+ /**
+ * Any arbitrarily large value, but must be at least twice [HTTP_TIMEOUT_MILLISECONDS]
+ */
+ private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000
+
+ @GuardedBy("itself")
+ val cache: MutableMap = Collections.synchronizedMap(
+ object : LinkedHashMap(100) {
+ private val CACHE_LIMIT = 50
+
+ override fun removeEldestEntry(eldest: Map.Entry): Boolean {
+ return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
+ }
+ })
+
+ @JvmStatic
+ fun clear() {
+ synchronized(cache) {
+ cache.clear()
+ }
+ }
+
+ @JvmStatic
+ fun fetchRequestIfNeeded(
+ playlistId: String,
+ playerHeaders: Map
+ ) {
+ Objects.requireNonNull(playlistId)
+ synchronized(cache) {
+ if (!cache.containsKey(playlistId)) {
+ cache[playlistId] = DeletePlaylistRequest(
+ playlistId,
+ playerHeaders
+ )
+ }
+ }
+ }
+
+ @JvmStatic
+ fun getRequestForPlaylistId(playlistId: String): DeletePlaylistRequest? {
+ synchronized(cache) {
+ return cache[playlistId]
+ }
+ }
+
+ private fun handleConnectionError(toastMessage: String, ex: Exception?) {
+ Logger.printInfo({ toastMessage }, ex)
+ }
+
+ private val REQUEST_HEADER_KEYS = arrayOf(
+ "Authorization", // Available only to logged-in users.
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ )
+
+ private fun sendRequest(
+ playlistId: String,
+ playerHeaders: Map
+ ): JSONObject? {
+ Objects.requireNonNull(playlistId)
+
+ val startTime = System.currentTimeMillis()
+ // 'playlist/delete' request does not require PoToken.
+ val clientType = YouTubeAppClient.ClientType.ANDROID
+ val clientTypeName = clientType.name
+ Logger.printDebug { "Fetching delete playlist request, playlistId: $playlistId, using client: $clientTypeName" }
+
+ try {
+ val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(
+ PlayerRoutes.DELETE_PLAYLIST,
+ clientType,
+ )
+ connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS
+ connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS
+
+ for (key in REQUEST_HEADER_KEYS) {
+ var value = playerHeaders[key]
+ if (value != null) {
+ connection.setRequestProperty(key, value)
+ }
+ }
+
+ val requestBody = PlayerRoutes.deletePlaylistRequestBody(playlistId)
+
+ connection.setFixedLengthStreamingMode(requestBody.size)
+ connection.outputStream.write(requestBody)
+
+ val responseCode = connection.responseCode
+ if (responseCode == 200) return Requester.parseJSONObject(connection)
+
+ handleConnectionError(
+ (clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.responseMessage),
+ null
+ )
+ } catch (ex: SocketTimeoutException) {
+ handleConnectionError("Connection timeout", ex)
+ } catch (ex: IOException) {
+ handleConnectionError("Network error", ex)
+ } catch (ex: Exception) {
+ Logger.printException({ "sendRequest failed" }, ex)
+ } finally {
+ Logger.printDebug { "playlist: " + playlistId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
+ }
+
+ return null
+ }
+
+ private fun parseResponse(json: JSONObject): Boolean? {
+ try {
+ return json.has("command")
+ } catch (e: JSONException) {
+ val jsonForMessage = json.toString()
+ Logger.printException(
+ { "Fetch failed while processing response data for response: $jsonForMessage" },
+ e
+ )
+ }
+
+ return null
+ }
+
+ private fun fetch(
+ playlistId: String,
+ playerHeaders: Map
+ ): Boolean? {
+ val json = sendRequest(playlistId, playerHeaders)
+ if (json != null) {
+ return parseResponse(json)
+ }
+
+ return null
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/EditPlaylistRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/EditPlaylistRequest.kt
new file mode 100644
index 000000000..7372cb89a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/EditPlaylistRequest.kt
@@ -0,0 +1,233 @@
+package app.revanced.extension.youtube.patches.utils.requests
+
+import androidx.annotation.GuardedBy
+import app.revanced.extension.shared.patches.client.YouTubeAppClient
+import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes
+import app.revanced.extension.shared.requests.Requester
+import app.revanced.extension.shared.utils.Logger
+import app.revanced.extension.shared.utils.Utils
+import app.revanced.extension.youtube.patches.utils.requests.EditPlaylistRequest.Companion.HTTP_TIMEOUT_MILLISECONDS
+import org.apache.commons.lang3.StringUtils
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.util.Collections
+import java.util.Objects
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+class EditPlaylistRequest private constructor(
+ private val videoId: String,
+ private val playlistId: String,
+ private val setVideoId: String?,
+ private val playerHeaders: Map,
+) {
+ private val future: Future = Utils.submitOnBackgroundThread {
+ fetch(
+ videoId,
+ playlistId,
+ setVideoId,
+ playerHeaders,
+ )
+ }
+
+ val result: String?
+ get() {
+ try {
+ return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS]
+ } catch (ex: TimeoutException) {
+ Logger.printInfo(
+ { "getResult timed out" },
+ ex
+ )
+ } catch (ex: InterruptedException) {
+ Logger.printException(
+ { "getResult interrupted" },
+ ex
+ )
+ Thread.currentThread().interrupt() // Restore interrupt status flag.
+ } catch (ex: ExecutionException) {
+ Logger.printException(
+ { "getResult failure" },
+ ex
+ )
+ }
+
+ return null
+ }
+
+ companion object {
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private const val HTTP_TIMEOUT_MILLISECONDS = 10 * 1000
+
+ /**
+ * Any arbitrarily large value, but must be at least twice [HTTP_TIMEOUT_MILLISECONDS]
+ */
+ private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000
+
+ @GuardedBy("itself")
+ val cache: MutableMap = Collections.synchronizedMap(
+ object : LinkedHashMap(100) {
+ private val CACHE_LIMIT = 50
+
+ override fun removeEldestEntry(eldest: Map.Entry): Boolean {
+ return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
+ }
+ })
+
+ @JvmStatic
+ fun clear() {
+ synchronized(cache) {
+ cache.clear()
+ }
+ }
+
+ @JvmStatic
+ fun clearVideoId(videoId: String) {
+ synchronized(cache) {
+ cache.remove(videoId)
+ }
+ }
+
+ @JvmStatic
+ fun fetchRequestIfNeeded(
+ videoId: String,
+ playlistId: String,
+ setVideoId: String?,
+ playerHeaders: Map
+ ) {
+ Objects.requireNonNull(videoId)
+ synchronized(cache) {
+ if (!cache.containsKey(videoId)) {
+ cache[videoId] = EditPlaylistRequest(
+ videoId,
+ playlistId,
+ setVideoId,
+ playerHeaders
+ )
+ }
+ }
+ }
+
+ @JvmStatic
+ fun getRequestForVideoId(videoId: String): EditPlaylistRequest? {
+ synchronized(cache) {
+ return cache[videoId]
+ }
+ }
+
+ private fun handleConnectionError(toastMessage: String, ex: Exception?) {
+ Logger.printInfo({ toastMessage }, ex)
+ }
+
+ private val REQUEST_HEADER_KEYS = arrayOf(
+ "Authorization", // Available only to logged-in users.
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ )
+
+ private fun sendRequest(
+ videoId: String,
+ playlistId: String,
+ setVideoId: String?,
+ playerHeaders: Map
+ ): JSONObject? {
+ Objects.requireNonNull(videoId)
+
+ val startTime = System.currentTimeMillis()
+ // 'browse/edit_playlist' request does not require PoToken.
+ val clientType = YouTubeAppClient.ClientType.ANDROID
+ val clientTypeName = clientType.name
+ Logger.printDebug { "Fetching edit playlist request, videoId: $videoId, playlistId: $playlistId, setVideoId: $setVideoId, using client: $clientTypeName" }
+
+ try {
+ val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(
+ PlayerRoutes.EDIT_PLAYLIST,
+ clientType
+ )
+ connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS
+ connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS
+
+ for (key in REQUEST_HEADER_KEYS) {
+ var value = playerHeaders[key]
+ if (value != null) {
+ connection.setRequestProperty(key, value)
+ }
+ }
+
+ val requestBody =
+ PlayerRoutes.editPlaylistRequestBody(
+ videoId = videoId,
+ playlistId = playlistId,
+ setVideoId = setVideoId,
+ )
+
+ connection.setFixedLengthStreamingMode(requestBody.size)
+ connection.outputStream.write(requestBody)
+
+ val responseCode = connection.responseCode
+ if (responseCode == 200) return Requester.parseJSONObject(connection)
+
+ handleConnectionError(
+ (clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.responseMessage),
+ null
+ )
+ } catch (ex: SocketTimeoutException) {
+ handleConnectionError("Connection timeout", ex)
+ } catch (ex: IOException) {
+ handleConnectionError("Network error", ex)
+ } catch (ex: Exception) {
+ Logger.printException({ "sendRequest failed" }, ex)
+ } finally {
+ Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
+ }
+
+ return null
+ }
+
+ private fun parseResponse(json: JSONObject, remove: Boolean): String? {
+ try {
+ if (json.getString("status") == "STATUS_SUCCEEDED") {
+ if (remove) {
+ return ""
+ }
+ val playlistEditResultsJSONObject = json.getJSONArray("playlistEditResults").get(0)
+
+ if (playlistEditResultsJSONObject is JSONObject) {
+ return playlistEditResultsJSONObject
+ .getJSONObject("playlistEditVideoAddedResultData")
+ .getString("setVideoId")
+ }
+ }
+ } catch (e: JSONException) {
+ val jsonForMessage = json.toString()
+ Logger.printException(
+ { "Fetch failed while processing response data for response: $jsonForMessage" },
+ e
+ )
+ }
+
+ return null
+ }
+
+ private fun fetch(
+ videoId: String,
+ playlistId: String,
+ setVideoId: String?,
+ playerHeaders: Map
+ ): String? {
+ val json = sendRequest(videoId, playlistId, setVideoId, playerHeaders)
+ if (json != null) {
+ return parseResponse(json, StringUtils.isNotEmpty(setVideoId))
+ }
+
+ return null
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/GetPlaylistsRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/GetPlaylistsRequest.kt
new file mode 100644
index 000000000..54f54f33c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/GetPlaylistsRequest.kt
@@ -0,0 +1,236 @@
+package app.revanced.extension.youtube.patches.utils.requests
+
+import androidx.annotation.GuardedBy
+import app.revanced.extension.shared.patches.client.YouTubeAppClient
+import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes
+import app.revanced.extension.shared.requests.Requester
+import app.revanced.extension.shared.utils.Logger
+import app.revanced.extension.shared.utils.Utils
+import app.revanced.extension.youtube.patches.utils.requests.GetPlaylistsRequest.Companion.HTTP_TIMEOUT_MILLISECONDS
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.util.Collections
+import java.util.Objects
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+class GetPlaylistsRequest private constructor(
+ private val playlistId: String,
+ private val playerHeaders: Map,
+) {
+ private val future: Future>> = Utils.submitOnBackgroundThread {
+ fetch(
+ playlistId,
+ playerHeaders,
+ )
+ }
+
+ val playlists: Array>?
+ get() {
+ try {
+ return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS]
+ } catch (ex: TimeoutException) {
+ Logger.printInfo(
+ { "getPlaylists timed out" },
+ ex
+ )
+ } catch (ex: InterruptedException) {
+ Logger.printException(
+ { "getPlaylists interrupted" },
+ ex
+ )
+ Thread.currentThread().interrupt() // Restore interrupt status flag.
+ } catch (ex: ExecutionException) {
+ Logger.printException(
+ { "getPlaylists failure" },
+ ex
+ )
+ }
+
+ return null
+ }
+
+ companion object {
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private const val HTTP_TIMEOUT_MILLISECONDS = 10 * 1000
+
+ /**
+ * Any arbitrarily large value, but must be at least twice [HTTP_TIMEOUT_MILLISECONDS]
+ */
+ private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000
+
+ @GuardedBy("itself")
+ val cache: MutableMap = Collections.synchronizedMap(
+ object : LinkedHashMap(100) {
+ private val CACHE_LIMIT = 50
+
+ override fun removeEldestEntry(eldest: Map.Entry): Boolean {
+ return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
+ }
+ })
+
+ @JvmStatic
+ fun clear() {
+ synchronized(cache) {
+ cache.clear()
+ }
+ }
+
+ @JvmStatic
+ fun fetchRequestIfNeeded(
+ playlistId: String,
+ playerHeaders: Map
+ ) {
+ Objects.requireNonNull(playlistId)
+ synchronized(cache) {
+ if (!cache.containsKey(playlistId)) {
+ cache[playlistId] = GetPlaylistsRequest(
+ playlistId,
+ playerHeaders
+ )
+ }
+ }
+ }
+
+ @JvmStatic
+ fun getRequestForPlaylistId(playlistId: String): GetPlaylistsRequest? {
+ synchronized(cache) {
+ return cache[playlistId]
+ }
+ }
+
+ private fun handleConnectionError(toastMessage: String, ex: Exception?) {
+ Logger.printInfo({ toastMessage }, ex)
+ }
+
+ private val REQUEST_HEADER_KEYS = arrayOf(
+ "Authorization", // Available only to logged-in users.
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ )
+
+ private fun sendRequest(
+ playlistId: String,
+ playerHeaders: Map
+ ): JSONObject? {
+ Objects.requireNonNull(playlistId)
+
+ val startTime = System.currentTimeMillis()
+ // 'playlist/get_add_to_playlist' request does not require PoToken.
+ val clientType = YouTubeAppClient.ClientType.ANDROID
+ val clientTypeName = clientType.name
+ Logger.printDebug { "Fetching get playlists request, playlistId: $playlistId, using client: $clientTypeName" }
+
+ try {
+ val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(
+ PlayerRoutes.GET_PLAYLISTS,
+ clientType
+ )
+ connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS
+ connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS
+
+ for (key in REQUEST_HEADER_KEYS) {
+ var value = playerHeaders[key]
+ if (value != null) {
+ connection.setRequestProperty(key, value)
+ }
+ }
+
+ val requestBody = PlayerRoutes.getPlaylistsRequestBody(playlistId)
+
+ connection.setFixedLengthStreamingMode(requestBody.size)
+ connection.outputStream.write(requestBody)
+
+ val responseCode = connection.responseCode
+ if (responseCode == 200) return Requester.parseJSONObject(connection)
+
+ handleConnectionError(
+ (clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.responseMessage),
+ null
+ )
+ } catch (ex: SocketTimeoutException) {
+ handleConnectionError("Connection timeout", ex)
+ } catch (ex: IOException) {
+ handleConnectionError("Network error", ex)
+ } catch (ex: Exception) {
+ Logger.printException({ "sendRequest failed" }, ex)
+ } finally {
+ Logger.printDebug { "playlist: " + playlistId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
+ }
+
+ return null
+ }
+
+ private fun parseResponse(json: JSONObject): Array>? {
+ try {
+ val addToPlaylistRendererJsonObject =
+ json.getJSONArray("contents").get(0)
+
+ if (addToPlaylistRendererJsonObject is JSONObject) {
+ val playlistsJsonArray =
+ addToPlaylistRendererJsonObject
+ .getJSONObject("addToPlaylistRenderer")
+ .getJSONArray("playlists")
+
+ val playlistsLength = playlistsJsonArray.length()
+ val playlists: Array?> =
+ arrayOfNulls(playlistsLength)
+
+ for (i in 0..playlistsLength - 1) {
+ val elementsJsonObject =
+ playlistsJsonArray.get(i)
+
+ if (elementsJsonObject is JSONObject) {
+ val playlistAddToOptionRendererJSONObject =
+ elementsJsonObject
+ .getJSONObject("playlistAddToOptionRenderer")
+
+ val playlistId = playlistAddToOptionRendererJSONObject
+ .getString("playlistId")
+ val playlistTitle =
+ (playlistAddToOptionRendererJSONObject
+ .getJSONObject("title")
+ .getJSONArray("runs")
+ .get(0) as JSONObject)
+ .getString("text")
+
+ playlists[i] = Pair(playlistId, playlistTitle)
+ }
+ }
+
+ val finalPlaylists = playlists.filterNotNull().toTypedArray()
+ if (finalPlaylists.isNotEmpty()) {
+ return finalPlaylists
+ }
+ }
+ } catch (e: JSONException) {
+ val jsonForMessage = json.toString()
+ Logger.printException(
+ { "Fetch failed while processing response data for response: $jsonForMessage" },
+ e
+ )
+ }
+
+ return null
+ }
+
+ private fun fetch(
+ playlistId: String,
+ playerHeaders: Map
+ ): Array>? {
+ val json = sendRequest(playlistId, playerHeaders)
+ if (json != null) {
+ return parseResponse(json)
+ }
+
+ return null
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/SavePlaylistRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/SavePlaylistRequest.kt
new file mode 100644
index 000000000..c5d3a611a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/requests/SavePlaylistRequest.kt
@@ -0,0 +1,203 @@
+package app.revanced.extension.youtube.patches.utils.requests
+
+import androidx.annotation.GuardedBy
+import app.revanced.extension.shared.patches.client.YouTubeAppClient
+import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes
+import app.revanced.extension.shared.requests.Requester
+import app.revanced.extension.shared.utils.Logger
+import app.revanced.extension.shared.utils.Utils
+import app.revanced.extension.youtube.patches.utils.requests.SavePlaylistRequest.Companion.HTTP_TIMEOUT_MILLISECONDS
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.util.Collections
+import java.util.Objects
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+class SavePlaylistRequest private constructor(
+ private val playlistId: String,
+ private val libraryId: String,
+ private val playerHeaders: Map,
+) {
+ private val future: Future = Utils.submitOnBackgroundThread {
+ fetch(
+ playlistId,
+ libraryId,
+ playerHeaders,
+ )
+ }
+
+ val result: Boolean?
+ get() {
+ try {
+ return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS]
+ } catch (ex: TimeoutException) {
+ Logger.printInfo(
+ { "getResult timed out" },
+ ex
+ )
+ } catch (ex: InterruptedException) {
+ Logger.printException(
+ { "getResult interrupted" },
+ ex
+ )
+ Thread.currentThread().interrupt() // Restore interrupt status flag.
+ } catch (ex: ExecutionException) {
+ Logger.printException(
+ { "getResult failure" },
+ ex
+ )
+ }
+
+ return null
+ }
+
+ companion object {
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private const val HTTP_TIMEOUT_MILLISECONDS = 10 * 1000
+
+ /**
+ * Any arbitrarily large value, but must be at least twice [HTTP_TIMEOUT_MILLISECONDS]
+ */
+ private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000
+
+ @GuardedBy("itself")
+ val cache: MutableMap = Collections.synchronizedMap(
+ object : LinkedHashMap(100) {
+ private val CACHE_LIMIT = 50
+
+ override fun removeEldestEntry(eldest: Map.Entry): Boolean {
+ return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
+ }
+ })
+
+ @JvmStatic
+ fun clear() {
+ synchronized(cache) {
+ cache.clear()
+ }
+ }
+
+ @JvmStatic
+ fun fetchRequestIfNeeded(
+ playlistId: String,
+ libraryId: String,
+ playerHeaders: Map
+ ) {
+ Objects.requireNonNull(playlistId)
+ synchronized(cache) {
+ cache[libraryId] = SavePlaylistRequest(
+ playlistId,
+ libraryId,
+ playerHeaders
+ )
+ }
+ }
+
+ @JvmStatic
+ fun getRequestForLibraryId(libraryId: String): SavePlaylistRequest? {
+ synchronized(cache) {
+ return cache[libraryId]
+ }
+ }
+
+ private fun handleConnectionError(toastMessage: String, ex: Exception?) {
+ Logger.printInfo({ toastMessage }, ex)
+ }
+
+ private val REQUEST_HEADER_KEYS = arrayOf(
+ "Authorization", // Available only to logged-in users.
+ "X-GOOG-API-FORMAT-VERSION",
+ "X-Goog-Visitor-Id"
+ )
+
+ private fun sendRequest(
+ playlistId: String,
+ libraryId: String,
+ playerHeaders: Map
+ ): JSONObject? {
+ Objects.requireNonNull(playlistId)
+ Objects.requireNonNull(libraryId)
+
+ val startTime = System.currentTimeMillis()
+ // 'browse/edit_playlist' request does not require PoToken.
+ val clientType = YouTubeAppClient.ClientType.ANDROID
+ val clientTypeName = clientType.name
+ Logger.printDebug { "Fetching edit playlist request, playlistId: $playlistId, libraryId: $libraryId, using client: $clientTypeName" }
+
+ try {
+ val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(
+ PlayerRoutes.EDIT_PLAYLIST,
+ clientType
+ )
+ connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS
+ connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS
+
+ for (key in REQUEST_HEADER_KEYS) {
+ var value = playerHeaders[key]
+ if (value != null) {
+ connection.setRequestProperty(key, value)
+ }
+ }
+
+ val requestBody =
+ PlayerRoutes.savePlaylistRequestBody(libraryId, playlistId)
+
+ connection.setFixedLengthStreamingMode(requestBody.size)
+ connection.outputStream.write(requestBody)
+
+ val responseCode = connection.responseCode
+ if (responseCode == 200) return Requester.parseJSONObject(connection)
+
+ handleConnectionError(
+ (clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.responseMessage),
+ null
+ )
+ } catch (ex: SocketTimeoutException) {
+ handleConnectionError("Connection timeout", ex)
+ } catch (ex: IOException) {
+ handleConnectionError("Network error", ex)
+ } catch (ex: Exception) {
+ Logger.printException({ "sendRequest failed" }, ex)
+ } finally {
+ Logger.printDebug { "playlistId: $playlistId libraryId: $libraryId took: ${(System.currentTimeMillis() - startTime)}ms" }
+ }
+
+ return null
+ }
+
+ private fun parseResponse(json: JSONObject): Boolean? {
+ try {
+ return json.getString("status") == "STATUS_SUCCEEDED"
+ } catch (e: JSONException) {
+ val jsonForMessage = json.toString()
+ Logger.printException(
+ { "Fetch failed while processing response data for response: $jsonForMessage" },
+ e
+ )
+ }
+
+ return null
+ }
+
+ private fun fetch(
+ playlistId: String,
+ libraryId: String,
+ playerHeaders: Map
+ ): Boolean? {
+ val json = sendRequest(playlistId, libraryId,playerHeaders)
+ if (json != null) {
+ return parseResponse(json)
+ }
+
+ return null
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
index b9eda5de2..f450f1974 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
@@ -182,12 +182,14 @@ public class Settings extends BaseSettings {
public static final BooleanSetting HIDE_NAVIGATION_SUBSCRIPTIONS_BUTTON = new BooleanSetting("revanced_hide_navigation_subscriptions_button", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_LABEL = new BooleanSetting("revanced_hide_navigation_label", FALSE, true);
public static final BooleanSetting SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_switch_create_with_notifications_button", TRUE, true, "revanced_switch_create_with_notifications_button_user_dialog_message");
- public static final BooleanSetting ENABLE_TRANSLUCENT_NAVIGATION_BAR = new BooleanSetting("revanced_enable_translucent_navigation_bar", FALSE, true, "revanced_enable_translucent_navigation_bar_user_dialog_message");
+ public static final BooleanSetting ENABLE_TRANSLUCENT_NAVIGATION_BAR = new BooleanSetting("revanced_enable_translucent_navigation_bar", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_hide_navigation_bar", FALSE, true);
// PreferenceScreen: General - Override buttons
- public static final BooleanSetting OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_playlist_download_button", FALSE);
- public static final BooleanSetting OVERRIDE_VIDEO_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_video_download_button", FALSE);
+ public static final BooleanSetting OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_playlist_download_button", FALSE, true);
+ public static final BooleanSetting OVERRIDE_VIDEO_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_video_download_button", FALSE, true);
+ public static final BooleanSetting OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER = new BooleanSetting("revanced_override_video_download_button_queue_manager", FALSE, true,
+ "revanced_queue_manager_user_dialog_message", parent(OVERRIDE_VIDEO_DOWNLOAD_BUTTON));
public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_PLAYLIST = new StringSetting("revanced_external_downloader_package_name_playlist", "com.deniscerri.ytdl");
public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_VIDEO = new StringSetting("revanced_external_downloader_package_name_video", "com.deniscerri.ytdl");
public static final BooleanSetting OVERRIDE_YOUTUBE_MUSIC_BUTTON = new BooleanSetting("revanced_override_youtube_music_button", FALSE, true
@@ -335,7 +337,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC = new BooleanSetting("revanced_hide_player_flyout_menu_listen_with_youtube_music", TRUE);
// PreferenceScreen: Player - Fullscreen
- public static final BooleanSetting DISABLE_ENGAGEMENT_PANEL = new BooleanSetting("revanced_disable_engagement_panel", FALSE);
+ public static final BooleanSetting DISABLE_ENGAGEMENT_PANEL = new BooleanSetting("revanced_disable_engagement_panel", FALSE, true);
public static final BooleanSetting ENTER_FULLSCREEN = new BooleanSetting("revanced_enter_fullscreen", FALSE);
public static final EnumSetting EXIT_FULLSCREEN = new EnumSetting<>("revanced_exit_fullscreen", FullscreenMode.DISABLED);
public static final BooleanSetting SHOW_VIDEO_TITLE_SECTION = new BooleanSetting("revanced_show_video_title_section", TRUE, true, parent(DISABLE_ENGAGEMENT_PANEL));
@@ -397,6 +399,8 @@ public class Settings extends BaseSettings {
public static final BooleanSetting OVERLAY_BUTTON_COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_overlay_button_copy_video_url_timestamp", FALSE);
public static final BooleanSetting OVERLAY_BUTTON_MUTE_VOLUME = new BooleanSetting("revanced_overlay_button_mute_volume", FALSE);
public static final BooleanSetting OVERLAY_BUTTON_EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_overlay_button_external_downloader", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_EXTERNAL_DOWNLOADER_QUEUE_MANAGER = new BooleanSetting("revanced_overlay_button_external_downloader_queue_manager", FALSE, true,
+ "revanced_queue_manager_user_dialog_message", parent(OVERLAY_BUTTON_EXTERNAL_DOWNLOADER));
public static final BooleanSetting OVERLAY_BUTTON_SPEED_DIALOG = new BooleanSetting("revanced_overlay_button_speed_dialog", FALSE);
public static final BooleanSetting OVERLAY_BUTTON_PLAY_ALL = new BooleanSetting("revanced_overlay_button_play_all", FALSE);
public static final EnumSetting OVERLAY_BUTTON_PLAY_ALL_TYPE = new EnumSetting<>("revanced_overlay_button_play_all_type", PlaylistIdPrefix.ALL_CONTENTS_WITH_TIME_DESCENDING);
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java
index c3c22520b..dfd02e7a7 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java
@@ -2,7 +2,6 @@ package app.revanced.extension.youtube.settings.preference;
import static app.revanced.extension.shared.utils.StringRef.str;
import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
-import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
import android.preference.Preference;
import android.preference.SwitchPreference;
@@ -43,7 +42,6 @@ public class ReVancedSettingsPreference extends ReVancedPreferenceFragment {
enableDisablePreferences();
AmbientModePreferenceLinks();
- ExternalDownloaderPreferenceLinks();
FullScreenPanelPreferenceLinks();
NavigationPreferenceLinks();
RYDPreferenceLinks();
@@ -65,18 +63,6 @@ public class ReVancedSettingsPreference extends ReVancedPreferenceFragment {
);
}
- /**
- * Enable/Disable Preference for External downloader settings
- */
- private static void ExternalDownloaderPreferenceLinks() {
- // Override download button will not work if spoofed with YouTube 18.24.xx or earlier.
- enableDisablePreferences(
- isSpoofingToLessThan("18.24.00"),
- Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON,
- Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON
- );
- }
-
/**
* Enable/Disable Preferences not working in tablet layout
*/
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ExtendedUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ExtendedUtils.java
index fae38500b..7713f1084 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ExtendedUtils.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/ExtendedUtils.java
@@ -2,8 +2,27 @@ package app.revanced.extension.youtube.utils;
import static app.revanced.extension.shared.utils.StringRef.str;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.graphics.ColorFilter;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.StateListDrawable;
+import android.view.Gravity;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
import androidx.annotation.NonNull;
+import java.util.Map;
+
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.FloatSetting;
import app.revanced.extension.shared.settings.IntegerSetting;
@@ -114,4 +133,88 @@ public class ExtendedUtils extends PackageUtils {
}
return additionalSettingsEnabled;
}
+
+ public static void showBottomSheetDialog(Context mContext, ScrollView mScrollView,
+ Map actionsMap) {
+ runOnMainThreadDelayed(() -> {
+ AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
+ builder.setView(mScrollView);
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+
+ actionsMap.forEach((view, action) ->
+ view.setOnClickListener(v -> {
+ action.run();
+ dialog.dismiss();
+ })
+ );
+ actionsMap.clear();
+
+ Window window = dialog.getWindow();
+ if (window == null) {
+ return;
+ }
+
+ // round corners
+ GradientDrawable dialogBackground = new GradientDrawable();
+ dialogBackground.setCornerRadius(32);
+ window.setBackgroundDrawable(dialogBackground);
+
+ // fit screen width
+ int dialogWidth = (int) (mContext.getResources().getDisplayMetrics().widthPixels * 0.95);
+ window.setLayout(dialogWidth, ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ // move dialog to bottom
+ WindowManager.LayoutParams layoutParams = window.getAttributes();
+ layoutParams.gravity = Gravity.BOTTOM;
+
+ // adjust the vertical offset
+ layoutParams.y = dpToPx(5);
+
+ window.setAttributes(layoutParams);
+ }, 250);
+ }
+
+ public static LinearLayout createItemLayout(Context mContext, String title, int iconId) {
+ // Item Layout
+ LinearLayout itemLayout = new LinearLayout(mContext);
+ itemLayout.setOrientation(LinearLayout.HORIZONTAL);
+ itemLayout.setPadding(dpToPx(16), dpToPx(12), dpToPx(16), dpToPx(12));
+ itemLayout.setGravity(Gravity.CENTER_VERTICAL);
+ itemLayout.setClickable(true);
+ itemLayout.setFocusable(true);
+
+ // Create a StateListDrawable for the background
+ StateListDrawable background = new StateListDrawable();
+ ColorDrawable pressedDrawable = new ColorDrawable(ThemeUtils.getPressedElementColor());
+ ColorDrawable defaultDrawable = new ColorDrawable(ThemeUtils.getBackgroundColor());
+ background.addState(new int[]{android.R.attr.state_pressed}, pressedDrawable);
+ background.addState(new int[]{}, defaultDrawable);
+ itemLayout.setBackground(background);
+
+ // Icon
+ ColorFilter cf = new PorterDuffColorFilter(ThemeUtils.getForegroundColor(), PorterDuff.Mode.SRC_ATOP);
+ ImageView iconView = new ImageView(mContext);
+ iconView.setImageResource(iconId);
+ iconView.setColorFilter(cf);
+ LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(dpToPx(24), dpToPx(24));
+ iconParams.setMarginEnd(dpToPx(16));
+ iconView.setLayoutParams(iconParams);
+ itemLayout.addView(iconView);
+
+ // Text container
+ LinearLayout textContainer = new LinearLayout(mContext);
+ textContainer.setOrientation(LinearLayout.VERTICAL);
+ TextView titleView = new TextView(mContext);
+ titleView.setText(title);
+ titleView.setTextSize(16);
+ titleView.setTextColor(ThemeUtils.getForegroundColor());
+ textContainer.addView(titleView);
+
+ itemLayout.addView(textContainer);
+
+ return itemLayout;
+ }
+
}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java
index 68f364829..5f614c62b 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java
@@ -63,7 +63,7 @@ public class VideoUtils extends IntentUtils {
return builder.toString();
}
- private static String getVideoScheme(String videoId, boolean isShorts) {
+ public static String getVideoScheme(String videoId, boolean isShorts) {
return String.format(
Locale.ENGLISH,
isShorts ? VIDEO_SCHEME_INTENT_FORMAT : VIDEO_SCHEME_LINK_FORMAT,
@@ -128,6 +128,22 @@ public class VideoUtils extends IntentUtils {
launchView(getChannelUrl(channelId), getContext().getPackageName());
}
+ public static void openPlaylist(@NonNull String playlistId) {
+ openPlaylist(playlistId, "");
+ }
+
+ public static void openPlaylist(@NonNull String playlistId, @NonNull String videoId) {
+ final StringBuilder sb = new StringBuilder();
+ if (videoId.isEmpty()) {
+ sb.append(getPlaylistUrl(playlistId));
+ } else {
+ sb.append(getVideoScheme(videoId, false));
+ sb.append("&list=");
+ sb.append(playlistId);
+ }
+ launchView(sb.toString(), getContext().getPackageName());
+ }
+
public static void openVideo() {
openVideo(VideoInformation.getVideoId());
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4376956d5..616c34f24 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,12 +6,14 @@ smali = "3.0.5"
gson = "2.12.1"
agp = "8.2.2"
annotation = "1.9.1"
+collections4 = "4.5.0-M3"
lang3 = "3.17.0"
preference = "1.2.1"
[libraries]
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" }
+collections4 = { module = "org.apache.commons:commons-collections4", version.ref = "collections4" }
lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "lang3" }
preference = { module = "androidx.preference:preference", version.ref = "preference" }
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/DownloadActionsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/DownloadActionsPatch.kt
index d6f4d54b4..7a36adba1 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/DownloadActionsPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/downloads/DownloadActionsPatch.kt
@@ -11,6 +11,7 @@ import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PAC
import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH
import app.revanced.patches.youtube.utils.patch.PatchList.HOOK_DOWNLOAD_ACTIONS
import app.revanced.patches.youtube.utils.pip.pipStateHookPatch
+import app.revanced.patches.youtube.utils.playlist.playlistPatch
import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
import app.revanced.patches.youtube.utils.settings.settingsPatch
@@ -41,6 +42,7 @@ val downloadActionsPatch = bytecodePatch(
dependsOn(
pipStateHookPatch,
+ playlistPatch,
sharedResourceIdPatch,
settingsPatch,
)
@@ -52,7 +54,7 @@ val downloadActionsPatch = bytecodePatch(
offlineVideoEndpointFingerprint.methodOrThrow().apply {
addInstructionsWithLabels(
0, """
- invoke-static/range {p3 .. p3}, $EXTENSION_CLASS_DESCRIPTOR->inAppVideoDownloadButtonOnClick(Ljava/lang/String;)Z
+ invoke-static/range {p1 .. p3}, $EXTENSION_CLASS_DESCRIPTOR->inAppVideoDownloadButtonOnClick(Ljava/util/Map;Ljava/lang/Object;Ljava/lang/String;)Z
move-result v0
if-eqz v0, :show_native_downloader
return-void
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt
index 67ce333ab..b2134cfc3 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/player/overlaybuttons/OverlayButtonsPatch.kt
@@ -15,6 +15,7 @@ import app.revanced.patches.youtube.utils.patch.PatchList.OVERLAY_BUTTONS
import app.revanced.patches.youtube.utils.pip.pipStateHookPatch
import app.revanced.patches.youtube.utils.playercontrols.hookBottomControlButton
import app.revanced.patches.youtube.utils.playercontrols.playerControlsPatch
+import app.revanced.patches.youtube.utils.playlist.playlistPatch
import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
import app.revanced.patches.youtube.utils.settings.settingsPatch
@@ -74,6 +75,7 @@ val overlayButtonsPatch = resourcePatch(
cfBottomUIPatch,
pipStateHookPatch,
playerControlsPatch,
+ playlistPatch,
sharedResourceIdPatch,
settingsPatch,
)
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playlist/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playlist/Fingerprints.kt
new file mode 100644
index 000000000..109484f0e
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playlist/Fingerprints.kt
@@ -0,0 +1,54 @@
+package app.revanced.patches.youtube.utils.playlist
+
+import app.revanced.util.fingerprint.legacyFingerprint
+import app.revanced.util.getReference
+import app.revanced.util.indexOfFirstInstruction
+import app.revanced.util.or
+import com.android.tools.smali.dexlib2.AccessFlags
+import com.android.tools.smali.dexlib2.Opcode
+import com.android.tools.smali.dexlib2.iface.Method
+import com.android.tools.smali.dexlib2.iface.reference.FieldReference
+
+internal val accountIdentityFingerprint = legacyFingerprint(
+ name = "accountIdentityFingerprint",
+ returnType = "V",
+ accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR,
+ customFingerprint = { method, _ ->
+ method.definingClass.endsWith("${'$'}AutoValue_AccountIdentity;")
+ }
+)
+
+internal val editPlaylistConstructorFingerprint = legacyFingerprint(
+ name = "editPlaylistConstructorFingerprint",
+ returnType = "V",
+ strings = listOf("browse/edit_playlist")
+)
+
+internal val editPlaylistFingerprint = legacyFingerprint(
+ name = "editPlaylistFingerprint",
+ returnType = "V",
+ accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
+ parameters = listOf("Ljava/util/List;"),
+ opcodes = listOf(
+ Opcode.CHECK_CAST,
+ Opcode.IGET_OBJECT,
+ ),
+)
+
+internal val playlistEndpointFingerprint = legacyFingerprint(
+ name = "playlistEndpointFingerprint",
+ returnType = "L",
+ parameters = listOf("L", "Ljava/lang/String;"),
+ customFingerprint = { method, _ ->
+ method.indexOfFirstInstruction {
+ opcode == Opcode.SGET_OBJECT &&
+ getReference()?.name == "playlistEditEndpoint"
+ } >= 0 && indexOfSetVideoIdInstruction(method) >= 0
+ }
+)
+
+internal fun indexOfSetVideoIdInstruction(method: Method) =
+ method.indexOfFirstInstruction {
+ opcode == Opcode.IPUT_OBJECT &&
+ getReference()?.type == "Ljava/lang/String;"
+ }
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playlist/PlaylistPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playlist/PlaylistPatch.kt
new file mode 100644
index 000000000..ce2bcccf7
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/playlist/PlaylistPatch.kt
@@ -0,0 +1,85 @@
+package app.revanced.patches.youtube.utils.playlist
+
+import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
+import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
+import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
+import app.revanced.patcher.patch.PatchException
+import app.revanced.patcher.patch.bytecodePatch
+import app.revanced.patches.shared.mainactivity.getMainActivityMethod
+import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH
+import app.revanced.patches.youtube.utils.extension.sharedExtensionPatch
+import app.revanced.patches.youtube.utils.mainactivity.mainActivityResolvePatch
+import app.revanced.patches.youtube.utils.request.buildRequestPatch
+import app.revanced.patches.youtube.utils.request.hookBuildRequest
+import app.revanced.util.fingerprint.matchOrThrow
+import app.revanced.util.fingerprint.methodOrThrow
+import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
+import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
+import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction
+import com.android.tools.smali.dexlib2.iface.reference.FieldReference
+
+private const val EXTENSION_CLASS_DESCRIPTOR =
+ "$UTILS_PATH/PlaylistPatch;"
+
+val playlistPatch = bytecodePatch(
+ description = "playlistPatch",
+) {
+ dependsOn(
+ sharedExtensionPatch,
+ mainActivityResolvePatch,
+ buildRequestPatch,
+ )
+
+ execute {
+ // In Incognito mode, sending a request always seems to fail.
+ accountIdentityFingerprint.methodOrThrow().addInstructions(
+ 1,
+ "invoke-static/range {p4 .. p4}, $EXTENSION_CLASS_DESCRIPTOR->setIncognitoStatus(Z)V"
+ )
+
+ // Get the header to use the auth token.
+ hookBuildRequest("$EXTENSION_CLASS_DESCRIPTOR->setRequestHeaders(Ljava/lang/String;Ljava/util/Map;)V")
+
+ // Open the queue manager by pressing and holding the back button.
+ getMainActivityMethod("onKeyLongPress")
+ .addInstructionsWithLabels(
+ 0, """
+ invoke-static/range {p1 .. p1}, $EXTENSION_CLASS_DESCRIPTOR->onKeyLongPress(I)Z
+ move-result v0
+ if-eqz v0, :ignore
+ return v0
+ :ignore
+ nop
+ """
+ )
+
+ val setVideoIdReference = with (playlistEndpointFingerprint.methodOrThrow()) {
+ val setVideoIdIndex = indexOfSetVideoIdInstruction(this)
+ getInstruction(setVideoIdIndex).reference as FieldReference
+ }
+
+ // Users deleted videos via YouTube's flyout menu.
+ editPlaylistFingerprint
+ .matchOrThrow(editPlaylistConstructorFingerprint)
+ .let {
+ it.method.apply {
+ val castIndex = it.patternMatch!!.startIndex
+ val castClass = getInstruction(castIndex).reference.toString()
+
+ if (castClass != setVideoIdReference.definingClass) {
+ throw PatchException("Method signature parameter did not match: $castClass")
+ }
+ val castRegister = getInstruction(castIndex).registerA
+ val insertIndex = castIndex + 1
+ val insertRegister = getInstruction(insertIndex).registerA
+
+ addInstructions(
+ insertIndex, """
+ iget-object v$insertRegister, v$castRegister, $setVideoIdReference
+ invoke-static {v$insertRegister}, $EXTENSION_CLASS_DESCRIPTOR->removeFromQueue(Ljava/lang/String;)V
+ """
+ )
+ }
+ }
+ }
+}
diff --git a/patches/src/main/resources/youtube/settings/host/values/strings.xml b/patches/src/main/resources/youtube/settings/host/values/strings.xml
index 3b18b2f2b..c1eddda0c 100644
--- a/patches/src/main/resources/youtube/settings/host/values/strings.xml
+++ b/patches/src/main/resources/youtube/settings/host/values/strings.xml
@@ -22,6 +22,40 @@
"%1$s is not installed.
Please download %2$s from the website."
%s is not installed. Please install it.
+
+ Add to queue
+ Add to queue and open queue
+ Add to queue and play video
+ External downloader
+ Open queue
+ Queue
+ Remove from queue
+ Remove from queue and open queue
+ Remove queue
+ Save queue
+ "Instead of opening an external downloader, open the queue manager dialog.
+
+You can also open the queue manager by pressing and holding the back button on the navigation bar.
+
+This feature is still in progress, so most features may not work.
+
+Please use it for debugging purposes only."
+ Login required
+ Queue manager unavailable (%s).
+ Could not identify playlist
+ Queue is empty
+ Could not identify video
+ Failed to add video.
+ Failed to create queue.
+ Failed to delete queue.
+ Failed to remove video.
+ Failed to save queue.
+ Video successfully added.
+ Queue successfully created.
+ Queue successfully deleted.
+ Video successfully removed.
+ Queue successfully saved to \'%s\'.
+
RVX language
App language
"Amharic
@@ -595,6 +629,9 @@ Some components may not be hidden."
Override video download button
Native video download button opens your external downloader.
Native video download button opens the native in-app downloader.
+ Queue manager
+ Native video download button opens the queue manager.
+ Native video download button opens your external downloader.
Playlist downloader package name
Package name of your installed external downloader app, such as YTDLnis.
@@ -652,7 +689,6 @@ If this setting do not take effect, try switching to Incognito mode."
Enable translucent navigation bar
Navigation bar is translucent.
Navigation bar is opaque.
- In certain YouTube versions, this setting can make the system navigation bar transparent or the layout can be broken in PIP mode.
Hide navigation bar
Navigation bar is hidden.
Navigation bar is shown.
@@ -1264,6 +1300,8 @@ Tap and hold to copy video timestamp."
Tap to mute volume of the current video. Tap again to unmute.
Show external downloader button
Tap to launch external downloader.
+ Queue manager
+ Instead of launching an external downloader, open the queue manager.
Show speed dialog button
"Tap to open speed dialog.
Tap and hold to reset playback speed to 1.0x. Tap and hold again to reset back to default speed."
diff --git a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml
index 8240aa8de..b869b7988 100644
--- a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml
+++ b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml
@@ -148,6 +148,7 @@
+
SETTINGS: HOOK_DOWNLOAD_ACTIONS -->
@@ -489,6 +490,7 @@
+