feat(YouTube - Hook download actions, Overlay buttons): Add Queue manager setting (Experimental)

This commit is contained in:
inotia00 2025-03-24 22:03:28 +09:00
parent a235454d80
commit c22391926b
23 changed files with 2212 additions and 155 deletions

View File

@ -26,6 +26,7 @@ android {
dependencies { dependencies {
compileOnly(libs.annotation) compileOnly(libs.annotation)
compileOnly(libs.preference) compileOnly(libs.preference)
implementation(libs.collections4)
implementation(libs.lang3) implementation(libs.lang3)
compileOnly(project(":extensions:shared:stub")) compileOnly(project(":extensions:shared:stub"))

View File

@ -7,8 +7,10 @@ import app.revanced.extension.shared.requests.Route
import app.revanced.extension.shared.requests.Route.CompiledRoute import app.revanced.extension.shared.requests.Route.CompiledRoute
import app.revanced.extension.shared.settings.BaseSettings import app.revanced.extension.shared.settings.BaseSettings
import app.revanced.extension.shared.utils.Logger import app.revanced.extension.shared.utils.Logger
import app.revanced.extension.shared.utils.StringRef.str
import app.revanced.extension.shared.utils.Utils import app.revanced.extension.shared.utils.Utils
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
@ -20,6 +22,38 @@ import java.util.TimeZone
@Suppress("deprecation") @Suppress("deprecation")
object PlayerRoutes { 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 @JvmField
val GET_CATEGORY: CompiledRoute = Route( val GET_CATEGORY: CompiledRoute = Route(
Route.Method.POST, Route.Method.POST,
@ -28,6 +62,16 @@ object PlayerRoutes {
"&fields=microformat.playerMicroformatRenderer.category" "&fields=microformat.playerMicroformatRenderer.category"
).compile() ).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 @JvmField
val GET_PLAYLIST_PAGE: CompiledRoute = Route( val GET_PLAYLIST_PAGE: CompiledRoute = Route(
Route.Method.POST, Route.Method.POST,
@ -172,6 +216,150 @@ object PlayerRoutes {
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8) 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 @JvmStatic
fun getPlayerResponseConnectionFromRoute( fun getPlayerResponseConnectionFromRoute(
route: CompiledRoute, route: CompiledRoute,

View File

@ -1,18 +1,34 @@
package app.revanced.extension.youtube.patches.general; 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.shared.utils.Logger;
import app.revanced.extension.youtube.patches.utils.PlaylistPatch;
import app.revanced.extension.youtube.settings.Settings; import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public final class DownloadActionsPatch extends VideoUtils { public final class DownloadActionsPatch {
private static final BooleanSetting overrideVideoDownloadButton = private static final boolean OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON =
Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON; Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON.get();
private static final BooleanSetting overridePlaylistDownloadButton = private static final boolean OVERRIDE_VIDEO_DOWNLOAD_BUTTON =
Settings.OVERRIDE_PLAYLIST_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. * Injection point.
@ -23,17 +39,21 @@ public final class DownloadActionsPatch extends VideoUtils {
* <p> * <p>
* Appears to always be called from the main thread. * Appears to always be called from the main thread.
*/ */
public static boolean inAppVideoDownloadButtonOnClick(String videoId) { public static boolean inAppVideoDownloadButtonOnClick(@Nullable Map<Object, Object> map,Object offlineVideoEndpointOuterClass,
@Nullable String videoId) {
try { try {
if (!overrideVideoDownloadButton.get()) { if (OVERRIDE_VIDEO_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(videoId)) {
return false; if (OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER) {
} if (map != null && map.get(ELEMENTS_SENDER_VIEW) instanceof View view) {
if (videoId == null || videoId.isEmpty()) { PlaylistPatch.setContext(view.getContext());
return false;
} }
PlaylistPatch.prepareDialogBuilder(videoId);
} else {
launchVideoExternalDownloader(videoId); launchVideoExternalDownloader(videoId);
}
return true; return true;
}
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "inAppVideoDownloadButtonOnClick failure", ex); Logger.printException(() -> "inAppVideoDownloadButtonOnClick failure", ex);
} }
@ -49,15 +69,10 @@ public final class DownloadActionsPatch extends VideoUtils {
*/ */
public static String inAppPlaylistDownloadButtonOnClick(String playlistId) { public static String inAppPlaylistDownloadButtonOnClick(String playlistId) {
try { try {
if (!overridePlaylistDownloadButton.get()) { if (OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(playlistId)) {
return playlistId;
}
if (playlistId == null || playlistId.isEmpty()) {
return playlistId;
}
launchPlaylistExternalDownloader(playlistId); launchPlaylistExternalDownloader(playlistId);
return ""; return "";
}
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "inAppPlaylistDownloadButtonOnClick failure", ex); Logger.printException(() -> "inAppPlaylistDownloadButtonOnClick failure", ex);
} }
@ -73,15 +88,10 @@ public final class DownloadActionsPatch extends VideoUtils {
*/ */
public static boolean inAppPlaylistDownloadMenuOnClick(String playlistId) { public static boolean inAppPlaylistDownloadMenuOnClick(String playlistId) {
try { try {
if (!overridePlaylistDownloadButton.get()) { if (OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(playlistId)) {
return false;
}
if (playlistId == null || playlistId.isEmpty()) {
return false;
}
launchPlaylistExternalDownloader(playlistId); launchPlaylistExternalDownloader(playlistId);
return true; return true;
}
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "inAppPlaylistDownloadMenuOnClick failure", ex); Logger.printException(() -> "inAppPlaylistDownloadMenuOnClick failure", ex);
} }
@ -92,7 +102,7 @@ public final class DownloadActionsPatch extends VideoUtils {
* Injection point. * Injection point.
*/ */
public static boolean overridePlaylistDownloadButtonVisibility() { public static boolean overridePlaylistDownloadButtonVisibility() {
return overridePlaylistDownloadButton.get(); return OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON;
} }
} }

View File

@ -6,7 +6,9 @@ import android.view.ViewGroup;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import app.revanced.extension.shared.utils.Logger; 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.settings.Settings;
import app.revanced.extension.youtube.shared.VideoInformation;
import app.revanced.extension.youtube.utils.VideoUtils; import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused") @SuppressWarnings("unused")
@ -19,7 +21,14 @@ public class ExternalDownload extends BottomControlButton {
bottomControlsViewGroup, bottomControlsViewGroup,
"external_download_button", "external_download_button",
Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER, 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 null
); );
} }

View File

@ -546,6 +546,10 @@ public class PlayerPatch {
return Settings.HIDE_FILMSTRIP_OVERLAY.get(); 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) { public static boolean hideInfoCard(boolean original) {
return !Settings.HIDE_INFO_CARDS.get() && original; return !Settings.HIDE_INFO_CARDS.get() && original;
} }

View File

@ -1,25 +1,14 @@
package app.revanced.extension.youtube.patches.shorts; package app.revanced.extension.youtube.patches.shorts;
import static app.revanced.extension.shared.utils.ResourceUtils.getString; 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.patches.components.ShortsCustomActionsFilter.isShortsFlyoutMenuVisible;
import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan; import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
import android.app.AlertDialog;
import android.content.Context; 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.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.StateListDrawable;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.view.Gravity;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ScrollView; 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.patches.components.ShortsCustomActionsFilter;
import app.revanced.extension.youtube.settings.Settings; import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.ShortsPlayerState; 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; import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused") @SuppressWarnings("unused")
@ -90,105 +79,28 @@ public final class CustomActionsPatch {
}), 0); }), 0);
} }
private static void showMoreButtonDialog(Context context) { private static void showMoreButtonDialog(Context mContext) {
ScrollView scrollView = new ScrollView(context); ScrollView mScrollView = new ScrollView(mContext);
LinearLayout container = new LinearLayout(context); LinearLayout mLinearLayout = new LinearLayout(mContext);
mLinearLayout.setOrientation(LinearLayout.VERTICAL);
mLinearLayout.setPadding(0, 0, 0, 0);
container.setOrientation(LinearLayout.VERTICAL); Map<LinearLayout, Runnable> actionsMap = new LinkedHashMap<>(arrSize);
container.setPadding(0, 0, 0, 0);
Map<LinearLayout, Runnable> toolbarMap = new LinkedHashMap<>(arrSize);
for (CustomAction customAction : CustomAction.values()) { for (CustomAction customAction : CustomAction.values()) {
if (customAction.settings.get()) { if (customAction.settings.get()) {
String title = customAction.getLabel(); String title = customAction.getLabel();
int iconId = customAction.getDrawableId(); int iconId = customAction.getDrawableId();
Runnable action = customAction.getOnClickAction(); Runnable action = customAction.getOnClickAction();
LinearLayout itemLayout = createItemLayout(context, title, iconId); LinearLayout itemLayout = ExtendedUtils.createItemLayout(mContext, title, iconId);
toolbarMap.putIfAbsent(itemLayout, action); actionsMap.putIfAbsent(itemLayout, action);
container.addView(itemLayout); mLinearLayout.addView(itemLayout);
} }
} }
scrollView.addView(container); mScrollView.addView(mLinearLayout);
AlertDialog.Builder builder = new AlertDialog.Builder(context); ExtendedUtils.showBottomSheetDialog(mContext, mScrollView, actionsMap);
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;
} }
private static boolean isMoreButton(String enumString) { private static boolean isMoreButton(String enumString) {

View File

@ -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<String, String> 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<String, String> 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<String, String> 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<LinearLayout, Runnable> 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<String, String> 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<String, String>[] 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<LinearLayout, Runnable> actionsMap = new LinkedHashMap<>(playlists.length);
int libraryIconId = QueueManager.SAVE_QUEUE.drawableId;
for (Pair<String, String> 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,
};
}
}

View File

@ -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<String, String>,
) {
private val future: Future<Pair<String, String>> = Utils.submitOnBackgroundThread {
fetch(videoId, playerHeaders)
}
val playlistId: Pair<String, String>?
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<String, CreatePlaylistRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, CreatePlaylistRequest>(100) {
private val CACHE_LIMIT = 50
override fun removeEldestEntry(eldest: Map.Entry<String, CreatePlaylistRequest>): 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<String, String>) {
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<String, String>): 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<String, String>): 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<String, String>): Pair<String, String>? {
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
}
}
}

View File

@ -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<String, String>,
) {
private val future: Future<Boolean> = 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<String, DeletePlaylistRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, DeletePlaylistRequest>(100) {
private val CACHE_LIMIT = 50
override fun removeEldestEntry(eldest: Map.Entry<String, DeletePlaylistRequest>): 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<String, String>
) {
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<String, String>
): 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<String, String>
): Boolean? {
val json = sendRequest(playlistId, playerHeaders)
if (json != null) {
return parseResponse(json)
}
return null
}
}
}

View File

@ -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<String, String>,
) {
private val future: Future<String> = 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<String, EditPlaylistRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, EditPlaylistRequest>(100) {
private val CACHE_LIMIT = 50
override fun removeEldestEntry(eldest: Map.Entry<String, EditPlaylistRequest>): 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<String, String>
) {
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<String, String>
): 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, String>
): String? {
val json = sendRequest(videoId, playlistId, setVideoId, playerHeaders)
if (json != null) {
return parseResponse(json, StringUtils.isNotEmpty(setVideoId))
}
return null
}
}
}

View File

@ -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<String, String>,
) {
private val future: Future<Array<Pair<String, String>>> = Utils.submitOnBackgroundThread {
fetch(
playlistId,
playerHeaders,
)
}
val playlists: Array<Pair<String, String>>?
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<String, GetPlaylistsRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, GetPlaylistsRequest>(100) {
private val CACHE_LIMIT = 50
override fun removeEldestEntry(eldest: Map.Entry<String, GetPlaylistsRequest>): 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<String, String>
) {
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<String, String>
): 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<Pair<String, String>>? {
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<Pair<String, String>?> =
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<String, String>
): Array<Pair<String, String>>? {
val json = sendRequest(playlistId, playerHeaders)
if (json != null) {
return parseResponse(json)
}
return null
}
}
}

View File

@ -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<String, String>,
) {
private val future: Future<Boolean> = 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<String, SavePlaylistRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, SavePlaylistRequest>(100) {
private val CACHE_LIMIT = 50
override fun removeEldestEntry(eldest: Map.Entry<String, SavePlaylistRequest>): 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<String, String>
) {
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<String, String>
): 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<String, String>
): Boolean? {
val json = sendRequest(playlistId, libraryId,playerHeaders)
if (json != null) {
return parseResponse(json)
}
return null
}
}
}

View File

@ -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_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 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 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); public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_hide_navigation_bar", FALSE, true);
// PreferenceScreen: General - Override buttons // 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_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); 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_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 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 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); 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 // 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 BooleanSetting ENTER_FULLSCREEN = new BooleanSetting("revanced_enter_fullscreen", FALSE);
public static final EnumSetting<FullscreenMode> EXIT_FULLSCREEN = new EnumSetting<>("revanced_exit_fullscreen", FullscreenMode.DISABLED); public static final EnumSetting<FullscreenMode> 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)); 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_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_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 = 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_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 BooleanSetting OVERLAY_BUTTON_PLAY_ALL = new BooleanSetting("revanced_overlay_button_play_all", FALSE);
public static final EnumSetting<PlaylistIdPrefix> OVERLAY_BUTTON_PLAY_ALL_TYPE = new EnumSetting<>("revanced_overlay_button_play_all_type", PlaylistIdPrefix.ALL_CONTENTS_WITH_TIME_DESCENDING); public static final EnumSetting<PlaylistIdPrefix> OVERLAY_BUTTON_PLAY_ALL_TYPE = new EnumSetting<>("revanced_overlay_button_play_all_type", PlaylistIdPrefix.ALL_CONTENTS_WITH_TIME_DESCENDING);

View File

@ -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.StringRef.str;
import static app.revanced.extension.shared.utils.Utils.isSDKAbove; 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.Preference;
import android.preference.SwitchPreference; import android.preference.SwitchPreference;
@ -43,7 +42,6 @@ public class ReVancedSettingsPreference extends ReVancedPreferenceFragment {
enableDisablePreferences(); enableDisablePreferences();
AmbientModePreferenceLinks(); AmbientModePreferenceLinks();
ExternalDownloaderPreferenceLinks();
FullScreenPanelPreferenceLinks(); FullScreenPanelPreferenceLinks();
NavigationPreferenceLinks(); NavigationPreferenceLinks();
RYDPreferenceLinks(); 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 * Enable/Disable Preferences not working in tablet layout
*/ */

View File

@ -2,8 +2,27 @@ package app.revanced.extension.youtube.utils;
import static app.revanced.extension.shared.utils.StringRef.str; 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 androidx.annotation.NonNull;
import java.util.Map;
import app.revanced.extension.shared.settings.BooleanSetting; import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.FloatSetting; import app.revanced.extension.shared.settings.FloatSetting;
import app.revanced.extension.shared.settings.IntegerSetting; import app.revanced.extension.shared.settings.IntegerSetting;
@ -114,4 +133,88 @@ public class ExtendedUtils extends PackageUtils {
} }
return additionalSettingsEnabled; return additionalSettingsEnabled;
} }
public static void showBottomSheetDialog(Context mContext, ScrollView mScrollView,
Map<LinearLayout, Runnable> 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;
}
} }

View File

@ -63,7 +63,7 @@ public class VideoUtils extends IntentUtils {
return builder.toString(); return builder.toString();
} }
private static String getVideoScheme(String videoId, boolean isShorts) { public static String getVideoScheme(String videoId, boolean isShorts) {
return String.format( return String.format(
Locale.ENGLISH, Locale.ENGLISH,
isShorts ? VIDEO_SCHEME_INTENT_FORMAT : VIDEO_SCHEME_LINK_FORMAT, isShorts ? VIDEO_SCHEME_INTENT_FORMAT : VIDEO_SCHEME_LINK_FORMAT,
@ -128,6 +128,22 @@ public class VideoUtils extends IntentUtils {
launchView(getChannelUrl(channelId), getContext().getPackageName()); 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() { public static void openVideo() {
openVideo(VideoInformation.getVideoId()); openVideo(VideoInformation.getVideoId());
} }

View File

@ -6,12 +6,14 @@ smali = "3.0.5"
gson = "2.12.1" gson = "2.12.1"
agp = "8.2.2" agp = "8.2.2"
annotation = "1.9.1" annotation = "1.9.1"
collections4 = "4.5.0-M3"
lang3 = "3.17.0" lang3 = "3.17.0"
preference = "1.2.1" preference = "1.2.1"
[libraries] [libraries]
gson = { module = "com.google.code.gson:gson", version.ref = "gson" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } 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" } lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "lang3" }
preference = { module = "androidx.preference:preference", version.ref = "preference" } preference = { module = "androidx.preference:preference", version.ref = "preference" }

View File

@ -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.extension.Constants.GENERAL_PATH
import app.revanced.patches.youtube.utils.patch.PatchList.HOOK_DOWNLOAD_ACTIONS 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.pip.pipStateHookPatch
import app.revanced.patches.youtube.utils.playlist.playlistPatch
import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
import app.revanced.patches.youtube.utils.settings.settingsPatch import app.revanced.patches.youtube.utils.settings.settingsPatch
@ -41,6 +42,7 @@ val downloadActionsPatch = bytecodePatch(
dependsOn( dependsOn(
pipStateHookPatch, pipStateHookPatch,
playlistPatch,
sharedResourceIdPatch, sharedResourceIdPatch,
settingsPatch, settingsPatch,
) )
@ -52,7 +54,7 @@ val downloadActionsPatch = bytecodePatch(
offlineVideoEndpointFingerprint.methodOrThrow().apply { offlineVideoEndpointFingerprint.methodOrThrow().apply {
addInstructionsWithLabels( addInstructionsWithLabels(
0, """ 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 move-result v0
if-eqz v0, :show_native_downloader if-eqz v0, :show_native_downloader
return-void return-void

View File

@ -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.pip.pipStateHookPatch
import app.revanced.patches.youtube.utils.playercontrols.hookBottomControlButton import app.revanced.patches.youtube.utils.playercontrols.hookBottomControlButton
import app.revanced.patches.youtube.utils.playercontrols.playerControlsPatch 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.resourceid.sharedResourceIdPatch
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
import app.revanced.patches.youtube.utils.settings.settingsPatch import app.revanced.patches.youtube.utils.settings.settingsPatch
@ -74,6 +75,7 @@ val overlayButtonsPatch = resourcePatch(
cfBottomUIPatch, cfBottomUIPatch,
pipStateHookPatch, pipStateHookPatch,
playerControlsPatch, playerControlsPatch,
playlistPatch,
sharedResourceIdPatch, sharedResourceIdPatch,
settingsPatch, settingsPatch,
) )

View File

@ -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<FieldReference>()?.name == "playlistEditEndpoint"
} >= 0 && indexOfSetVideoIdInstruction(method) >= 0
}
)
internal fun indexOfSetVideoIdInstruction(method: Method) =
method.indexOfFirstInstruction {
opcode == Opcode.IPUT_OBJECT &&
getReference<FieldReference>()?.type == "Ljava/lang/String;"
}

View File

@ -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<ReferenceInstruction>(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<ReferenceInstruction>(castIndex).reference.toString()
if (castClass != setVideoIdReference.definingClass) {
throw PatchException("Method signature parameter did not match: $castClass")
}
val castRegister = getInstruction<OneRegisterInstruction>(castIndex).registerA
val insertIndex = castIndex + 1
val insertRegister = getInstruction<TwoRegisterInstruction>(insertIndex).registerA
addInstructions(
insertIndex, """
iget-object v$insertRegister, v$castRegister, $setVideoIdReference
invoke-static {v$insertRegister}, $EXTENSION_CLASS_DESCRIPTOR->removeFromQueue(Ljava/lang/String;)V
"""
)
}
}
}
}

View File

@ -22,6 +22,40 @@
<string name="revanced_external_downloader_not_installed_dialog_message">"%1$s is not installed. <string name="revanced_external_downloader_not_installed_dialog_message">"%1$s is not installed.
Please download %2$s from the website."</string> Please download %2$s from the website."</string>
<string name="revanced_external_downloader_not_installed_warning">%s is not installed. Please install it.</string> <string name="revanced_external_downloader_not_installed_warning">%s is not installed. Please install it.</string>
<string name="revanced_queue_manager_add_to_queue">Add to queue</string>
<string name="revanced_queue_manager_add_to_queue_and_open_queue">Add to queue and open queue</string>
<string name="revanced_queue_manager_add_to_queue_and_play_video">Add to queue and play video</string>
<string name="revanced_queue_manager_external_downloader">External downloader</string>
<string name="revanced_queue_manager_open_queue">Open queue</string>
<string name="revanced_queue_manager_queue">Queue</string>
<string name="revanced_queue_manager_remove_from_queue">Remove from queue</string>
<string name="revanced_queue_manager_remove_from_queue_and_open_queue">Remove from queue and open queue</string>
<string name="revanced_queue_manager_remove_queue">Remove queue</string>
<string name="revanced_queue_manager_save_queue">Save queue</string>
<string name="revanced_queue_manager_user_dialog_message">"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."</string>
<string name="revanced_queue_manager_check_failed_auth">Login required</string>
<string name="revanced_queue_manager_check_failed_generic">Queue manager unavailable (%s).</string>
<string name="revanced_queue_manager_check_failed_playlist_id">Could not identify playlist</string>
<string name="revanced_queue_manager_check_failed_queue">Queue is empty</string>
<string name="revanced_queue_manager_check_failed_video_id">Could not identify video</string>
<string name="revanced_queue_manager_fetch_failed_add">Failed to add video.</string>
<string name="revanced_queue_manager_fetch_failed_create">Failed to create queue.</string>
<string name="revanced_queue_manager_fetch_failed_delete">Failed to delete queue.</string>
<string name="revanced_queue_manager_fetch_failed_remove">Failed to remove video.</string>
<string name="revanced_queue_manager_fetch_failed_save">Failed to save queue.</string>
<string name="revanced_queue_manager_fetch_succeeded_add">Video successfully added.</string>
<string name="revanced_queue_manager_fetch_succeeded_create">Queue successfully created.</string>
<string name="revanced_queue_manager_fetch_succeeded_delete">Queue successfully deleted.</string>
<string name="revanced_queue_manager_fetch_succeeded_remove">Video successfully removed.</string>
<string name="revanced_queue_manager_fetch_succeeded_save">Queue successfully saved to \'%s\'.</string>
<string name="revanced_language_title">RVX language</string> <string name="revanced_language_title">RVX language</string>
<string name="revanced_language_DEFAULT">App language</string> <string name="revanced_language_DEFAULT">App language</string>
<string name="revanced_language_AM">"Amharic <string name="revanced_language_AM">"Amharic
@ -595,6 +629,9 @@ Some components may not be hidden."</string>
<string name="revanced_override_video_download_button_title">Override video download button</string> <string name="revanced_override_video_download_button_title">Override video download button</string>
<string name="revanced_override_video_download_button_summary_on">Native video download button opens your external downloader.</string> <string name="revanced_override_video_download_button_summary_on">Native video download button opens your external downloader.</string>
<string name="revanced_override_video_download_button_summary_off">Native video download button opens the native in-app downloader.</string> <string name="revanced_override_video_download_button_summary_off">Native video download button opens the native in-app downloader.</string>
<string name="revanced_override_video_download_button_queue_manager_title">Queue manager</string>
<string name="revanced_override_video_download_button_queue_manager_summary_on">Native video download button opens the queue manager.</string>
<string name="revanced_override_video_download_button_queue_manager_summary_off">Native video download button opens your external downloader.</string>
<string name="revanced_external_downloader_package_name_playlist_title">Playlist downloader package name</string> <string name="revanced_external_downloader_package_name_playlist_title">Playlist downloader package name</string>
<string name="revanced_external_downloader_package_name_playlist_summary">Package name of your installed external downloader app, such as YTDLnis.</string> <string name="revanced_external_downloader_package_name_playlist_summary">Package name of your installed external downloader app, such as YTDLnis.</string>
@ -652,7 +689,6 @@ If this setting do not take effect, try switching to Incognito mode."</string>
<string name="revanced_enable_translucent_navigation_bar_title">Enable translucent navigation bar</string> <string name="revanced_enable_translucent_navigation_bar_title">Enable translucent navigation bar</string>
<string name="revanced_enable_translucent_navigation_bar_summary_on">Navigation bar is translucent.</string> <string name="revanced_enable_translucent_navigation_bar_summary_on">Navigation bar is translucent.</string>
<string name="revanced_enable_translucent_navigation_bar_summary_off">Navigation bar is opaque.</string> <string name="revanced_enable_translucent_navigation_bar_summary_off">Navigation bar is opaque.</string>
<string name="revanced_enable_translucent_navigation_bar_user_dialog_message">In certain YouTube versions, this setting can make the system navigation bar transparent or the layout can be broken in PIP mode.</string>
<string name="revanced_hide_navigation_bar_title">Hide navigation bar</string> <string name="revanced_hide_navigation_bar_title">Hide navigation bar</string>
<string name="revanced_hide_navigation_bar_summary_on">Navigation bar is hidden.</string> <string name="revanced_hide_navigation_bar_summary_on">Navigation bar is hidden.</string>
<string name="revanced_hide_navigation_bar_summary_off">Navigation bar is shown.</string> <string name="revanced_hide_navigation_bar_summary_off">Navigation bar is shown.</string>
@ -1264,6 +1300,8 @@ Tap and hold to copy video timestamp."</string>
<string name="revanced_overlay_button_mute_volume_summary">Tap to mute volume of the current video. Tap again to unmute.</string> <string name="revanced_overlay_button_mute_volume_summary">Tap to mute volume of the current video. Tap again to unmute.</string>
<string name="revanced_overlay_button_external_downloader_title">Show external downloader button</string> <string name="revanced_overlay_button_external_downloader_title">Show external downloader button</string>
<string name="revanced_overlay_button_external_downloader_summary">Tap to launch external downloader.</string> <string name="revanced_overlay_button_external_downloader_summary">Tap to launch external downloader.</string>
<string name="revanced_overlay_button_external_downloader_queue_manager_title">Queue manager</string>
<string name="revanced_overlay_button_external_downloader_queue_manager_summary">Instead of launching an external downloader, open the queue manager.</string>
<string name="revanced_overlay_button_speed_dialog_title">Show speed dialog button</string> <string name="revanced_overlay_button_speed_dialog_title">Show speed dialog button</string>
<string name="revanced_overlay_button_speed_dialog_summary">"Tap to open speed dialog. <string name="revanced_overlay_button_speed_dialog_summary">"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."</string> Tap and hold to reset playback speed to 1.0x. Tap and hold again to reset back to default speed."</string>

View File

@ -148,6 +148,7 @@
<PreferenceCategory android:title="@string/revanced_preference_category_download_button" android:layout="@layout/revanced_settings_preferences_category" /> <PreferenceCategory android:title="@string/revanced_preference_category_download_button" android:layout="@layout/revanced_settings_preferences_category" />
<SwitchPreference android:title="@string/revanced_override_playlist_download_button_title" android:key="revanced_override_playlist_download_button" android:summaryOn="@string/revanced_override_playlist_download_button_summary_on" android:summaryOff="@string/revanced_override_playlist_download_button_summary_off" /> <SwitchPreference android:title="@string/revanced_override_playlist_download_button_title" android:key="revanced_override_playlist_download_button" android:summaryOn="@string/revanced_override_playlist_download_button_summary_on" android:summaryOff="@string/revanced_override_playlist_download_button_summary_off" />
<SwitchPreference android:title="@string/revanced_override_video_download_button_title" android:key="revanced_override_video_download_button" android:summaryOn="@string/revanced_override_video_download_button_summary_on" android:summaryOff="@string/revanced_override_video_download_button_summary_off" /> <SwitchPreference android:title="@string/revanced_override_video_download_button_title" android:key="revanced_override_video_download_button" android:summaryOn="@string/revanced_override_video_download_button_summary_on" android:summaryOff="@string/revanced_override_video_download_button_summary_off" />
<SwitchPreference android:title="@string/revanced_override_video_download_button_queue_manager_title" android:key="revanced_override_video_download_button_queue_manager" android:summaryOn="@string/revanced_override_video_download_button_queue_manager_summary_on" android:summaryOff="@string/revanced_override_video_download_button_queue_manager_summary_off" />
<app.revanced.extension.youtube.settings.preference.ExternalDownloaderPlaylistPreference android:title="@string/revanced_external_downloader_package_name_playlist_title" android:key="revanced_external_downloader_package_name_playlist" android:summary="@string/revanced_external_downloader_package_name_playlist_summary" /> <app.revanced.extension.youtube.settings.preference.ExternalDownloaderPlaylistPreference android:title="@string/revanced_external_downloader_package_name_playlist_title" android:key="revanced_external_downloader_package_name_playlist" android:summary="@string/revanced_external_downloader_package_name_playlist_summary" />
<app.revanced.extension.youtube.settings.preference.ExternalDownloaderVideoPreference android:title="@string/revanced_external_downloader_package_name_video_title" android:key="revanced_external_downloader_package_name_video" android:summary="@string/revanced_external_downloader_package_name_video_summary" />SETTINGS: HOOK_DOWNLOAD_ACTIONS --> <app.revanced.extension.youtube.settings.preference.ExternalDownloaderVideoPreference android:title="@string/revanced_external_downloader_package_name_video_title" android:key="revanced_external_downloader_package_name_video" android:summary="@string/revanced_external_downloader_package_name_video_summary" />SETTINGS: HOOK_DOWNLOAD_ACTIONS -->
@ -489,6 +490,7 @@
<SwitchPreference android:title="@string/revanced_overlay_button_mute_volume_title" android:key="revanced_overlay_button_mute_volume" android:summary="@string/revanced_overlay_button_mute_volume_summary" /> <SwitchPreference android:title="@string/revanced_overlay_button_mute_volume_title" android:key="revanced_overlay_button_mute_volume" android:summary="@string/revanced_overlay_button_mute_volume_summary" />
<SwitchPreference android:title="@string/revanced_overlay_button_speed_dialog_title" android:key="revanced_overlay_button_speed_dialog" android:summary="@string/revanced_overlay_button_speed_dialog_summary" /> <SwitchPreference android:title="@string/revanced_overlay_button_speed_dialog_title" android:key="revanced_overlay_button_speed_dialog" android:summary="@string/revanced_overlay_button_speed_dialog_summary" />
<SwitchPreference android:title="@string/revanced_overlay_button_external_downloader_title" android:key="revanced_overlay_button_external_downloader" android:summary="@string/revanced_overlay_button_external_downloader_summary" /> <SwitchPreference android:title="@string/revanced_overlay_button_external_downloader_title" android:key="revanced_overlay_button_external_downloader" android:summary="@string/revanced_overlay_button_external_downloader_summary" />
<SwitchPreference android:title="@string/revanced_overlay_button_external_downloader_queue_manager_title" android:key="revanced_overlay_button_external_downloader_queue_manager" android:summary="@string/revanced_overlay_button_external_downloader_queue_manager_summary" />
<app.revanced.extension.youtube.settings.preference.ExternalDownloaderVideoPreference android:title="@string/revanced_external_downloader_package_name_video_title" android:key="revanced_external_downloader_package_name_video" android:summary="@string/revanced_external_downloader_package_name_video_summary" /> <app.revanced.extension.youtube.settings.preference.ExternalDownloaderVideoPreference android:title="@string/revanced_external_downloader_package_name_video_title" android:key="revanced_external_downloader_package_name_video" android:summary="@string/revanced_external_downloader_package_name_video_summary" />
<SwitchPreference android:title="@string/revanced_overlay_button_play_all_title" android:key="revanced_overlay_button_play_all" android:summary="@string/revanced_overlay_button_play_all_summary" /> <SwitchPreference android:title="@string/revanced_overlay_button_play_all_title" android:key="revanced_overlay_button_play_all" android:summary="@string/revanced_overlay_button_play_all_summary" />
<ListPreference android:entries="@array/revanced_overlay_button_play_all_type_entries" android:title="@string/revanced_overlay_button_play_all_type_title" android:key="revanced_overlay_button_play_all_type" android:entryValues="@array/revanced_overlay_button_play_all_type_entry_values" android:dependency="revanced_overlay_button_play_all" /> <ListPreference android:entries="@array/revanced_overlay_button_play_all_type_entries" android:title="@string/revanced_overlay_button_play_all_type_title" android:key="revanced_overlay_button_play_all_type" android:entryValues="@array/revanced_overlay_button_play_all_type_entry_values" android:dependency="revanced_overlay_button_play_all" />