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 {
compileOnly(libs.annotation)
compileOnly(libs.preference)
implementation(libs.collections4)
implementation(libs.lang3)
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.settings.BaseSettings
import app.revanced.extension.shared.utils.Logger
import app.revanced.extension.shared.utils.StringRef.str
import app.revanced.extension.shared.utils.Utils
import org.apache.commons.lang3.StringUtils
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
@ -20,6 +22,38 @@ import java.util.TimeZone
@Suppress("deprecation")
object PlayerRoutes {
@JvmField
val CREATE_PLAYLIST: CompiledRoute = Route(
Route.Method.POST,
"playlist/create" +
"?prettyPrint=false" +
"&fields=playlistId"
).compile()
@JvmField
val DELETE_PLAYLIST: CompiledRoute = Route(
Route.Method.POST,
"playlist/delete" +
"?prettyPrint=false"
).compile()
@JvmField
val EDIT_PLAYLIST: CompiledRoute = Route(
Route.Method.POST,
"browse/edit_playlist" +
"?prettyPrint=false" +
"&fields=status," +
"playlistEditResults"
).compile()
@JvmField
val GET_PLAYLISTS: CompiledRoute = Route(
Route.Method.POST,
"playlist/get_add_to_playlist" +
"?prettyPrint=false" +
"&fields=contents.addToPlaylistRenderer.playlists.playlistAddToOptionRenderer"
).compile()
@JvmField
val GET_CATEGORY: CompiledRoute = Route(
Route.Method.POST,
@ -28,6 +62,16 @@ object PlayerRoutes {
"&fields=microformat.playerMicroformatRenderer.category"
).compile()
@JvmField
val GET_SET_VIDEO_ID: CompiledRoute = Route(
Route.Method.POST,
"next" +
"?prettyPrint=false" +
"&fields=contents.singleColumnWatchNextResults." +
"playlist.playlist.contents.playlistPanelVideoRenderer." +
"playlistSetVideoId"
).compile()
@JvmField
val GET_PLAYLIST_PAGE: CompiledRoute = Route(
Route.Method.POST,
@ -172,6 +216,150 @@ object PlayerRoutes {
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
}
private fun androidInnerTubeBody(
clientType: YouTubeAppClient.ClientType = YouTubeAppClient.ClientType.ANDROID
): JSONObject {
val innerTubeBody = JSONObject()
try {
val client = JSONObject()
client.put("deviceMake", clientType.deviceMake)
client.put("deviceModel", clientType.deviceModel)
client.put("clientName", clientType.clientName)
client.put("clientVersion", clientType.clientVersion)
client.put("osName", clientType.osName)
client.put("osVersion", clientType.osVersion)
client.put("androidSdkVersion", clientType.androidSdkVersion)
if (clientType.gmscoreVersionCode != null) {
client.put("gmscoreVersionCode", clientType.gmscoreVersionCode)
}
client.put(
"hl",
LOCALE_LANGUAGE
)
client.put("gl", LOCALE_COUNTRY)
client.put("timeZone", TIME_ZONE_ID)
client.put("utcOffsetMinutes", "$UTC_OFFSET_MINUTES")
val context = JSONObject()
context.put("client", client)
innerTubeBody.put("context", context)
innerTubeBody.put("contentCheckOk", true)
innerTubeBody.put("racyCheckOk", true)
} catch (e: JSONException) {
Logger.printException({ "Failed to create android innerTubeBody" }, e)
}
return innerTubeBody
}
@JvmStatic
fun createPlaylistRequestBody(
videoId: String,
): ByteArray {
val innerTubeBody = androidInnerTubeBody()
try {
innerTubeBody.put("params", "CAQ%3D")
// TODO: Implement an AlertDialog that allows changing the title of the playlist.
innerTubeBody.put("title", str("revanced_queue_manager_queue"))
val videoIds = JSONArray()
videoIds.put(0, videoId)
innerTubeBody.put("videoIds", videoIds)
} catch (e: JSONException) {
Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
}
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
}
@JvmStatic
fun deletePlaylistRequestBody(
playlistId: String,
): ByteArray {
val innerTubeBody = androidInnerTubeBody()
try {
innerTubeBody.put("playlistId", playlistId)
} catch (e: JSONException) {
Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
}
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
}
@JvmStatic
fun editPlaylistRequestBody(
videoId: String,
playlistId: String,
setVideoId: String?,
): ByteArray {
val innerTubeBody = androidInnerTubeBody()
try {
innerTubeBody.put("playlistId", playlistId)
val actionsObject = JSONObject()
if (setVideoId != null && setVideoId.isNotEmpty()) {
actionsObject.put("action", "ACTION_REMOVE_VIDEO")
actionsObject.put("setVideoId", setVideoId)
} else {
actionsObject.put("action", "ACTION_ADD_VIDEO")
actionsObject.put("addedVideoId", videoId)
}
val actionsArray = JSONArray()
actionsArray.put(0, actionsObject)
innerTubeBody.put("actions", actionsArray)
} catch (e: JSONException) {
Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
}
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
}
@JvmStatic
fun getPlaylistsRequestBody(
playlistId: String,
): ByteArray {
val innerTubeBody = androidInnerTubeBody()
try {
innerTubeBody.put("playlistId", playlistId)
innerTubeBody.put("excludeWatchLater", false)
} catch (e: JSONException) {
Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
}
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
}
@JvmStatic
fun savePlaylistRequestBody(
playlistId: String,
libraryId: String,
): ByteArray {
val innerTubeBody = androidInnerTubeBody()
try {
innerTubeBody.put("playlistId", playlistId)
val actionsObject = JSONObject()
actionsObject.put("action", "ACTION_ADD_PLAYLIST")
actionsObject.put("addedFullListId", libraryId)
val actionsArray = JSONArray()
actionsArray.put(0, actionsObject)
innerTubeBody.put("actions", actionsArray)
} catch (e: JSONException) {
Logger.printException({ "Failed to create playlist innerTubeBody" }, e)
}
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
}
@JvmStatic
fun getPlayerResponseConnectionFromRoute(
route: CompiledRoute,

View File

@ -1,18 +1,34 @@
package app.revanced.extension.youtube.patches.general;
import app.revanced.extension.shared.settings.BooleanSetting;
import static app.revanced.extension.youtube.utils.VideoUtils.launchPlaylistExternalDownloader;
import static app.revanced.extension.youtube.utils.VideoUtils.launchVideoExternalDownloader;
import android.view.View;
import androidx.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.youtube.patches.utils.PlaylistPatch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused")
public final class DownloadActionsPatch extends VideoUtils {
public final class DownloadActionsPatch {
private static final BooleanSetting overrideVideoDownloadButton =
Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON;
private static final boolean OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON =
Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON.get();
private static final BooleanSetting overridePlaylistDownloadButton =
Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON;
private static final boolean OVERRIDE_VIDEO_DOWNLOAD_BUTTON =
Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON.get();
private static final boolean OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER =
OVERRIDE_VIDEO_DOWNLOAD_BUTTON && Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER.get();
private static final String ELEMENTS_SENDER_VIEW =
"com.google.android.libraries.youtube.rendering.elements.sender_view";
/**
* Injection point.
@ -23,17 +39,21 @@ public final class DownloadActionsPatch extends VideoUtils {
* <p>
* 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 {
if (!overrideVideoDownloadButton.get()) {
return false;
}
if (videoId == null || videoId.isEmpty()) {
return false;
}
launchVideoExternalDownloader(videoId);
if (OVERRIDE_VIDEO_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(videoId)) {
if (OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER) {
if (map != null && map.get(ELEMENTS_SENDER_VIEW) instanceof View view) {
PlaylistPatch.setContext(view.getContext());
}
PlaylistPatch.prepareDialogBuilder(videoId);
} else {
launchVideoExternalDownloader(videoId);
}
return true;
return true;
}
} catch (Exception ex) {
Logger.printException(() -> "inAppVideoDownloadButtonOnClick failure", ex);
}
@ -49,15 +69,10 @@ public final class DownloadActionsPatch extends VideoUtils {
*/
public static String inAppPlaylistDownloadButtonOnClick(String playlistId) {
try {
if (!overridePlaylistDownloadButton.get()) {
return playlistId;
if (OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(playlistId)) {
launchPlaylistExternalDownloader(playlistId);
return "";
}
if (playlistId == null || playlistId.isEmpty()) {
return playlistId;
}
launchPlaylistExternalDownloader(playlistId);
return "";
} catch (Exception ex) {
Logger.printException(() -> "inAppPlaylistDownloadButtonOnClick failure", ex);
}
@ -73,15 +88,10 @@ public final class DownloadActionsPatch extends VideoUtils {
*/
public static boolean inAppPlaylistDownloadMenuOnClick(String playlistId) {
try {
if (!overridePlaylistDownloadButton.get()) {
return false;
if (OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON && StringUtils.isNotEmpty(playlistId)) {
launchPlaylistExternalDownloader(playlistId);
return true;
}
if (playlistId == null || playlistId.isEmpty()) {
return false;
}
launchPlaylistExternalDownloader(playlistId);
return true;
} catch (Exception ex) {
Logger.printException(() -> "inAppPlaylistDownloadMenuOnClick failure", ex);
}
@ -92,7 +102,7 @@ public final class DownloadActionsPatch extends VideoUtils {
* Injection point.
*/
public static boolean overridePlaylistDownloadButtonVisibility() {
return overridePlaylistDownloadButton.get();
return OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON;
}
}

View File

@ -6,7 +6,9 @@ import android.view.ViewGroup;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.youtube.patches.utils.PlaylistPatch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.VideoInformation;
import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused")
@ -19,7 +21,14 @@ public class ExternalDownload extends BottomControlButton {
bottomControlsViewGroup,
"external_download_button",
Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER,
view -> VideoUtils.launchVideoExternalDownloader(),
view -> {
if (Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER_QUEUE_MANAGER.get()) {
PlaylistPatch.setContext(view.getContext());
PlaylistPatch.prepareDialogBuilder(VideoInformation.getVideoId());
} else {
VideoUtils.launchVideoExternalDownloader();
}
},
null
);
}

View File

@ -546,6 +546,10 @@ public class PlayerPatch {
return Settings.HIDE_FILMSTRIP_OVERLAY.get();
}
public static boolean hideFilmstripOverlay(boolean original) {
return !Settings.HIDE_FILMSTRIP_OVERLAY.get() && original;
}
public static boolean hideInfoCard(boolean original) {
return !Settings.HIDE_INFO_CARDS.get() && original;
}

View File

@ -1,25 +1,14 @@
package app.revanced.extension.youtube.patches.shorts;
import static app.revanced.extension.shared.utils.ResourceUtils.getString;
import static app.revanced.extension.shared.utils.Utils.dpToPx;
import static app.revanced.extension.youtube.patches.components.ShortsCustomActionsFilter.isShortsFlyoutMenuVisible;
import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
import android.app.AlertDialog;
import android.content.Context;
import android.graphics.ColorFilter;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.StateListDrawable;
import android.support.v7.widget.RecyclerView;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
@ -42,7 +31,7 @@ import app.revanced.extension.shared.utils.Utils;
import app.revanced.extension.youtube.patches.components.ShortsCustomActionsFilter;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.ShortsPlayerState;
import app.revanced.extension.youtube.utils.ThemeUtils;
import app.revanced.extension.youtube.utils.ExtendedUtils;
import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused")
@ -90,105 +79,28 @@ public final class CustomActionsPatch {
}), 0);
}
private static void showMoreButtonDialog(Context context) {
ScrollView scrollView = new ScrollView(context);
LinearLayout container = new LinearLayout(context);
private static void showMoreButtonDialog(Context mContext) {
ScrollView mScrollView = new ScrollView(mContext);
LinearLayout mLinearLayout = new LinearLayout(mContext);
mLinearLayout.setOrientation(LinearLayout.VERTICAL);
mLinearLayout.setPadding(0, 0, 0, 0);
container.setOrientation(LinearLayout.VERTICAL);
container.setPadding(0, 0, 0, 0);
Map<LinearLayout, Runnable> toolbarMap = new LinkedHashMap<>(arrSize);
Map<LinearLayout, Runnable> actionsMap = new LinkedHashMap<>(arrSize);
for (CustomAction customAction : CustomAction.values()) {
if (customAction.settings.get()) {
String title = customAction.getLabel();
int iconId = customAction.getDrawableId();
Runnable action = customAction.getOnClickAction();
LinearLayout itemLayout = createItemLayout(context, title, iconId);
toolbarMap.putIfAbsent(itemLayout, action);
container.addView(itemLayout);
LinearLayout itemLayout = ExtendedUtils.createItemLayout(mContext, title, iconId);
actionsMap.putIfAbsent(itemLayout, action);
mLinearLayout.addView(itemLayout);
}
}
scrollView.addView(container);
mScrollView.addView(mLinearLayout);
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setView(scrollView);
AlertDialog dialog = builder.create();
dialog.show();
toolbarMap.forEach((view, action) ->
view.setOnClickListener(v -> {
action.run();
dialog.dismiss();
})
);
toolbarMap.clear();
Window window = dialog.getWindow();
if (window == null) {
return;
}
// round corners
GradientDrawable dialogBackground = new GradientDrawable();
dialogBackground.setCornerRadius(32);
window.setBackgroundDrawable(dialogBackground);
// fit screen width
int dialogWidth = (int) (context.getResources().getDisplayMetrics().widthPixels * 0.95);
window.setLayout(dialogWidth, ViewGroup.LayoutParams.WRAP_CONTENT);
// move dialog to bottom
WindowManager.LayoutParams layoutParams = window.getAttributes();
layoutParams.gravity = Gravity.BOTTOM;
// adjust the vertical offset
layoutParams.y = dpToPx(5);
window.setAttributes(layoutParams);
}
private static LinearLayout createItemLayout(Context context, String title, int iconId) {
// Item Layout
LinearLayout itemLayout = new LinearLayout(context);
itemLayout.setOrientation(LinearLayout.HORIZONTAL);
itemLayout.setPadding(dpToPx(16), dpToPx(12), dpToPx(16), dpToPx(12));
itemLayout.setGravity(Gravity.CENTER_VERTICAL);
itemLayout.setClickable(true);
itemLayout.setFocusable(true);
// Create a StateListDrawable for the background
StateListDrawable background = new StateListDrawable();
ColorDrawable pressedDrawable = new ColorDrawable(ThemeUtils.getPressedElementColor());
ColorDrawable defaultDrawable = new ColorDrawable(ThemeUtils.getBackgroundColor());
background.addState(new int[]{android.R.attr.state_pressed}, pressedDrawable);
background.addState(new int[]{}, defaultDrawable);
itemLayout.setBackground(background);
// Icon
ColorFilter cf = new PorterDuffColorFilter(ThemeUtils.getForegroundColor(), PorterDuff.Mode.SRC_ATOP);
ImageView iconView = new ImageView(context);
iconView.setImageResource(iconId);
iconView.setColorFilter(cf);
LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(dpToPx(24), dpToPx(24));
iconParams.setMarginEnd(dpToPx(16));
iconView.setLayoutParams(iconParams);
itemLayout.addView(iconView);
// Text container
LinearLayout textContainer = new LinearLayout(context);
textContainer.setOrientation(LinearLayout.VERTICAL);
TextView titleView = new TextView(context);
titleView.setText(title);
titleView.setTextSize(16);
titleView.setTextColor(ThemeUtils.getForegroundColor());
textContainer.addView(titleView);
itemLayout.addView(textContainer);
return itemLayout;
ExtendedUtils.showBottomSheetDialog(mContext, mScrollView, actionsMap);
}
private static boolean isMoreButton(String enumString) {

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_LABEL = new BooleanSetting("revanced_hide_navigation_label", FALSE, true);
public static final BooleanSetting SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_switch_create_with_notifications_button", TRUE, true, "revanced_switch_create_with_notifications_button_user_dialog_message");
public static final BooleanSetting ENABLE_TRANSLUCENT_NAVIGATION_BAR = new BooleanSetting("revanced_enable_translucent_navigation_bar", FALSE, true, "revanced_enable_translucent_navigation_bar_user_dialog_message");
public static final BooleanSetting ENABLE_TRANSLUCENT_NAVIGATION_BAR = new BooleanSetting("revanced_enable_translucent_navigation_bar", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_hide_navigation_bar", FALSE, true);
// PreferenceScreen: General - Override buttons
public static final BooleanSetting OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_playlist_download_button", FALSE);
public static final BooleanSetting OVERRIDE_VIDEO_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_video_download_button", FALSE);
public static final BooleanSetting OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_playlist_download_button", FALSE, true);
public static final BooleanSetting OVERRIDE_VIDEO_DOWNLOAD_BUTTON = new BooleanSetting("revanced_override_video_download_button", FALSE, true);
public static final BooleanSetting OVERRIDE_VIDEO_DOWNLOAD_BUTTON_QUEUE_MANAGER = new BooleanSetting("revanced_override_video_download_button_queue_manager", FALSE, true,
"revanced_queue_manager_user_dialog_message", parent(OVERRIDE_VIDEO_DOWNLOAD_BUTTON));
public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_PLAYLIST = new StringSetting("revanced_external_downloader_package_name_playlist", "com.deniscerri.ytdl");
public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_VIDEO = new StringSetting("revanced_external_downloader_package_name_video", "com.deniscerri.ytdl");
public static final BooleanSetting OVERRIDE_YOUTUBE_MUSIC_BUTTON = new BooleanSetting("revanced_override_youtube_music_button", FALSE, true
@ -335,7 +337,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC = new BooleanSetting("revanced_hide_player_flyout_menu_listen_with_youtube_music", TRUE);
// PreferenceScreen: Player - Fullscreen
public static final BooleanSetting DISABLE_ENGAGEMENT_PANEL = new BooleanSetting("revanced_disable_engagement_panel", FALSE);
public static final BooleanSetting DISABLE_ENGAGEMENT_PANEL = new BooleanSetting("revanced_disable_engagement_panel", FALSE, true);
public static final BooleanSetting ENTER_FULLSCREEN = new BooleanSetting("revanced_enter_fullscreen", FALSE);
public static final EnumSetting<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));
@ -397,6 +399,8 @@ public class Settings extends BaseSettings {
public static final BooleanSetting OVERLAY_BUTTON_COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_overlay_button_copy_video_url_timestamp", FALSE);
public static final BooleanSetting OVERLAY_BUTTON_MUTE_VOLUME = new BooleanSetting("revanced_overlay_button_mute_volume", FALSE);
public static final BooleanSetting OVERLAY_BUTTON_EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_overlay_button_external_downloader", FALSE);
public static final BooleanSetting OVERLAY_BUTTON_EXTERNAL_DOWNLOADER_QUEUE_MANAGER = new BooleanSetting("revanced_overlay_button_external_downloader_queue_manager", FALSE, true,
"revanced_queue_manager_user_dialog_message", parent(OVERLAY_BUTTON_EXTERNAL_DOWNLOADER));
public static final BooleanSetting OVERLAY_BUTTON_SPEED_DIALOG = new BooleanSetting("revanced_overlay_button_speed_dialog", FALSE);
public static final BooleanSetting OVERLAY_BUTTON_PLAY_ALL = new BooleanSetting("revanced_overlay_button_play_all", FALSE);
public static final EnumSetting<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.Utils.isSDKAbove;
import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
import android.preference.Preference;
import android.preference.SwitchPreference;
@ -43,7 +42,6 @@ public class ReVancedSettingsPreference extends ReVancedPreferenceFragment {
enableDisablePreferences();
AmbientModePreferenceLinks();
ExternalDownloaderPreferenceLinks();
FullScreenPanelPreferenceLinks();
NavigationPreferenceLinks();
RYDPreferenceLinks();
@ -65,18 +63,6 @@ public class ReVancedSettingsPreference extends ReVancedPreferenceFragment {
);
}
/**
* Enable/Disable Preference for External downloader settings
*/
private static void ExternalDownloaderPreferenceLinks() {
// Override download button will not work if spoofed with YouTube 18.24.xx or earlier.
enableDisablePreferences(
isSpoofingToLessThan("18.24.00"),
Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON,
Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON
);
}
/**
* Enable/Disable Preferences not working in tablet layout
*/

View File

@ -2,8 +2,27 @@ package app.revanced.extension.youtube.utils;
import static app.revanced.extension.shared.utils.StringRef.str;
import android.app.AlertDialog;
import android.content.Context;
import android.graphics.ColorFilter;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.StateListDrawable;
import android.view.Gravity;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import java.util.Map;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.FloatSetting;
import app.revanced.extension.shared.settings.IntegerSetting;
@ -114,4 +133,88 @@ public class ExtendedUtils extends PackageUtils {
}
return additionalSettingsEnabled;
}
public static void showBottomSheetDialog(Context mContext, ScrollView mScrollView,
Map<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();
}
private static String getVideoScheme(String videoId, boolean isShorts) {
public static String getVideoScheme(String videoId, boolean isShorts) {
return String.format(
Locale.ENGLISH,
isShorts ? VIDEO_SCHEME_INTENT_FORMAT : VIDEO_SCHEME_LINK_FORMAT,
@ -128,6 +128,22 @@ public class VideoUtils extends IntentUtils {
launchView(getChannelUrl(channelId), getContext().getPackageName());
}
public static void openPlaylist(@NonNull String playlistId) {
openPlaylist(playlistId, "");
}
public static void openPlaylist(@NonNull String playlistId, @NonNull String videoId) {
final StringBuilder sb = new StringBuilder();
if (videoId.isEmpty()) {
sb.append(getPlaylistUrl(playlistId));
} else {
sb.append(getVideoScheme(videoId, false));
sb.append("&list=");
sb.append(playlistId);
}
launchView(sb.toString(), getContext().getPackageName());
}
public static void openVideo() {
openVideo(VideoInformation.getVideoId());
}