mirror of
https://github.com/inotia00/revanced-patches.git
synced 2025-04-29 14:14:36 +02:00
feat(YouTube - Hook download actions, Overlay buttons): Add Queue manager
setting (Experimental)
This commit is contained in:
parent
a235454d80
commit
c22391926b
@ -26,6 +26,7 @@ android {
|
||||
dependencies {
|
||||
compileOnly(libs.annotation)
|
||||
compileOnly(libs.preference)
|
||||
implementation(libs.collections4)
|
||||
implementation(libs.lang3)
|
||||
|
||||
compileOnly(project(":extensions:shared:stub"))
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -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());
|
||||
}
|
||||
|
@ -6,12 +6,14 @@ smali = "3.0.5"
|
||||
gson = "2.12.1"
|
||||
agp = "8.2.2"
|
||||
annotation = "1.9.1"
|
||||
collections4 = "4.5.0-M3"
|
||||
lang3 = "3.17.0"
|
||||
preference = "1.2.1"
|
||||
|
||||
[libraries]
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" }
|
||||
collections4 = { module = "org.apache.commons:commons-collections4", version.ref = "collections4" }
|
||||
lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "lang3" }
|
||||
preference = { module = "androidx.preference:preference", version.ref = "preference" }
|
||||
|
||||
|
@ -11,6 +11,7 @@ import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PAC
|
||||
import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH
|
||||
import app.revanced.patches.youtube.utils.patch.PatchList.HOOK_DOWNLOAD_ACTIONS
|
||||
import app.revanced.patches.youtube.utils.pip.pipStateHookPatch
|
||||
import app.revanced.patches.youtube.utils.playlist.playlistPatch
|
||||
import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch
|
||||
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
|
||||
import app.revanced.patches.youtube.utils.settings.settingsPatch
|
||||
@ -41,6 +42,7 @@ val downloadActionsPatch = bytecodePatch(
|
||||
|
||||
dependsOn(
|
||||
pipStateHookPatch,
|
||||
playlistPatch,
|
||||
sharedResourceIdPatch,
|
||||
settingsPatch,
|
||||
)
|
||||
@ -52,7 +54,7 @@ val downloadActionsPatch = bytecodePatch(
|
||||
offlineVideoEndpointFingerprint.methodOrThrow().apply {
|
||||
addInstructionsWithLabels(
|
||||
0, """
|
||||
invoke-static/range {p3 .. p3}, $EXTENSION_CLASS_DESCRIPTOR->inAppVideoDownloadButtonOnClick(Ljava/lang/String;)Z
|
||||
invoke-static/range {p1 .. p3}, $EXTENSION_CLASS_DESCRIPTOR->inAppVideoDownloadButtonOnClick(Ljava/util/Map;Ljava/lang/Object;Ljava/lang/String;)Z
|
||||
move-result v0
|
||||
if-eqz v0, :show_native_downloader
|
||||
return-void
|
||||
|
@ -15,6 +15,7 @@ import app.revanced.patches.youtube.utils.patch.PatchList.OVERLAY_BUTTONS
|
||||
import app.revanced.patches.youtube.utils.pip.pipStateHookPatch
|
||||
import app.revanced.patches.youtube.utils.playercontrols.hookBottomControlButton
|
||||
import app.revanced.patches.youtube.utils.playercontrols.playerControlsPatch
|
||||
import app.revanced.patches.youtube.utils.playlist.playlistPatch
|
||||
import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch
|
||||
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
|
||||
import app.revanced.patches.youtube.utils.settings.settingsPatch
|
||||
@ -74,6 +75,7 @@ val overlayButtonsPatch = resourcePatch(
|
||||
cfBottomUIPatch,
|
||||
pipStateHookPatch,
|
||||
playerControlsPatch,
|
||||
playlistPatch,
|
||||
sharedResourceIdPatch,
|
||||
settingsPatch,
|
||||
)
|
||||
|
@ -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;"
|
||||
}
|
@ -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
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -22,6 +22,40 @@
|
||||
<string name="revanced_external_downloader_not_installed_dialog_message">"%1$s is not installed.
|
||||
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_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_DEFAULT">App language</string>
|
||||
<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_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_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_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_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_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_summary_on">Navigation bar is hidden.</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_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_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_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>
|
||||
|
@ -148,6 +148,7 @@
|
||||
<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_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.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_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_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" />
|
||||
<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" />
|
||||
|
Loading…
x
Reference in New Issue
Block a user