diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsCustomActionsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsCustomActionsFilter.java
new file mode 100644
index 000000000..c40b1ea2d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsCustomActionsFilter.java
@@ -0,0 +1,171 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+
+import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup;
+import app.revanced.extension.shared.patches.components.ByteArrayFilterGroupList;
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.TrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class ShortsCustomActionsFilter extends Filter {
+ private static final boolean IS_SPOOFING_TO_YOUTUBE_2023 =
+ isSpoofingToLessThan("19.00.00");
+ private static final boolean SHORTS_CUSTOM_ACTIONS_ENABLED =
+ !IS_SPOOFING_TO_YOUTUBE_2023 &&
+ Settings.ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU.get();
+
+ /**
+ * Last unique video id's loaded. Value is ignored and Map is treated as a Set.
+ * Cannot use {@link LinkedHashSet} because it's missing #removeEldestEntry().
+ */
+ @GuardedBy("itself")
+ private static final Map lastVideoIds = new LinkedHashMap<>() {
+ /**
+ * Number of video id's to keep track of for searching thru the buffer.
+ * A minimum value of 3 should be sufficient, but check a few more just in case.
+ */
+ private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 5;
+
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK;
+ }
+ };
+ private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList();
+
+ private final StringFilterGroup playerFlyoutMenu;
+
+ private final StringFilterGroup likeDislikeButton;
+
+ public static volatile boolean isShortsFlyoutMenuVisible;
+
+ public ShortsCustomActionsFilter() {
+ likeDislikeButton = new StringFilterGroup(
+ null,
+ "|shorts_like_button.eml",
+ "|shorts_dislike_button.eml"
+ );
+ playerFlyoutMenu = new StringFilterGroup(
+ null,
+ "overflow_menu_item.eml|"
+ );
+
+ addIdentifierCallbacks(playerFlyoutMenu);
+ addPathCallbacks(likeDislikeButton);
+
+ // After the button identifiers is binary data and then the video id for that specific short.
+ videoIdFilterGroup.addAll(
+ new ByteArrayFilterGroup(null, "id.reel_like_button"),
+ new ByteArrayFilterGroup(null, "id.reel_dislike_button")
+ );
+ }
+
+ private volatile static String shortsVideoId = "";
+
+ private static void setShortsVideoId(@NonNull String videoId, boolean isLive) {
+ if (shortsVideoId.equals(videoId)) {
+ return;
+ }
+ final String prefix = isLive ? "New Short livestream video id: " : "New Short video id: ";
+ Logger.printDebug(() -> prefix + videoId);
+ shortsVideoId = videoId;
+ }
+
+ public static String getShortsVideoId() {
+ return shortsVideoId;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void newShortsVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ if (!SHORTS_CUSTOM_ACTIONS_ENABLED) {
+ return;
+ }
+ if (!newlyLoadedLiveStreamValue) {
+ return;
+ }
+ setShortsVideoId(newlyLoadedVideoId, true);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) {
+ try {
+ if (!SHORTS_CUSTOM_ACTIONS_ENABLED) {
+ return;
+ }
+ if (!isShortAndOpeningOrPlaying) {
+ return;
+ }
+ synchronized (lastVideoIds) {
+ lastVideoIds.putIfAbsent(videoId, Boolean.TRUE);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "newPlayerResponseVideoId failure", ex);
+ }
+ }
+
+
+ /**
+ * This could use {@link TrieSearch}, but since the patterns are constantly changing
+ * the overhead of updating the Trie might negate the search performance gain.
+ */
+ private static boolean byteArrayContainsString(@NonNull byte[] array, @NonNull String text) {
+ for (int i = 0, lastArrayStartIndex = array.length - text.length(); i <= lastArrayStartIndex; i++) {
+ boolean found = true;
+ for (int j = 0, textLength = text.length(); j < textLength; j++) {
+ if (array[i + j] != (byte) text.charAt(j)) {
+ found = false;
+ break;
+ }
+ }
+ if (found) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (!SHORTS_CUSTOM_ACTIONS_ENABLED) {
+ return false;
+ }
+ if (matchedGroup == playerFlyoutMenu) {
+ isShortsFlyoutMenuVisible = true;
+ findVideoId(protobufBufferArray);
+ } else if (matchedGroup == likeDislikeButton && videoIdFilterGroup.check(protobufBufferArray).isFiltered()) {
+ findVideoId(protobufBufferArray);
+ }
+
+ return false;
+ }
+
+ private void findVideoId(byte[] protobufBufferArray) {
+ synchronized (lastVideoIds) {
+ for (String videoId : lastVideoIds.keySet()) {
+ if (byteArrayContainsString(protobufBufferArray, videoId)) {
+ setShortsVideoId(videoId, false);
+ }
+ }
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/CustomActionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/CustomActionsPatch.java
new file mode 100644
index 000000000..062e2265a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/CustomActionsPatch.java
@@ -0,0 +1,287 @@
+package app.revanced.extension.youtube.patches.shorts;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getString;
+import static app.revanced.extension.youtube.patches.components.ShortsCustomActionsFilter.isShortsFlyoutMenuVisible;
+import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.ref.WeakReference;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+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.components.ShortsCustomActionsFilter;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.ShortsPlayerState;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public final class CustomActionsPatch {
+ private static final boolean IS_SPOOFING_TO_YOUTUBE_2023 =
+ isSpoofingToLessThan("19.00.00");
+ private static final boolean SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU_ENABLED =
+ !IS_SPOOFING_TO_YOUTUBE_2023 && Settings.ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU.get();
+
+ private static final int arrSize = CustomAction.values().length;
+ private static final Map flyoutMenuMap = new LinkedHashMap<>(arrSize);
+ private static WeakReference contextRef = new WeakReference<>(null);
+ private static WeakReference recyclerViewRef = new WeakReference<>(null);
+
+ /**
+ * Injection point.
+ */
+ public static void setFlyoutMenuObject(Object bottomSheetMenuObject) {
+ if (!SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU_ENABLED) {
+ return;
+ }
+ if (ShortsPlayerState.getCurrent().isClosed()) {
+ return;
+ }
+ if (bottomSheetMenuObject == null) {
+ return;
+ }
+ for (CustomAction customAction : CustomAction.values()) {
+ flyoutMenuMap.putIfAbsent(customAction, bottomSheetMenuObject);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void addFlyoutMenu(Object bottomSheetMenuClass, Object bottomSheetMenuList) {
+ if (!SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU_ENABLED) {
+ return;
+ }
+ if (ShortsPlayerState.getCurrent().isClosed()) {
+ return;
+ }
+ for (CustomAction customAction : CustomAction.values()) {
+ if (customAction.settings.get()) {
+ addFlyoutMenu(bottomSheetMenuClass, bottomSheetMenuList, customAction);
+ }
+ }
+ }
+
+ /**
+ * Rest of the implementation added by patch.
+ */
+ private static void addFlyoutMenu(Object bottomSheetMenuClass, Object bottomSheetMenuList, CustomAction customAction) {
+ Object bottomSheetMenuObject = flyoutMenuMap.get(customAction);
+ // These instructions are ignored by patch.
+ Logger.printInfo(() -> customAction.name() + bottomSheetMenuClass + bottomSheetMenuList + bottomSheetMenuObject);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onFlyoutMenuCreate(final RecyclerView recyclerView) {
+ if (!SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU_ENABLED) {
+ return;
+ }
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ if (ShortsPlayerState.getCurrent().isClosed()) {
+ return;
+ }
+ contextRef = new WeakReference<>(recyclerView.getContext());
+ if (!isShortsFlyoutMenuVisible) {
+ return;
+ }
+ int childCount = recyclerView.getChildCount();
+ if (childCount < arrSize + 1) {
+ return;
+ }
+ for (int i = 0; i < arrSize; i++) {
+ if (recyclerView.getChildAt(childCount - i - 1) instanceof ViewGroup parentViewGroup) {
+ childCount = recyclerView.getChildCount();
+ if (childCount > 3 && parentViewGroup.getChildAt(1) instanceof TextView textView) {
+ for (CustomAction customAction : CustomAction.values()) {
+ if (customAction.getLabel().equals(textView.getText().toString())) {
+ View.OnClickListener onClick = customAction.getOnClickListener();
+ View.OnLongClickListener onLongClick = customAction.getOnLongClickListener();
+ recyclerViewRef = new WeakReference<>(recyclerView);
+ parentViewGroup.setOnClickListener(onClick);
+ if (onLongClick != null) {
+ parentViewGroup.setOnLongClickListener(onLongClick);
+ }
+ }
+ }
+ }
+ }
+ }
+ isShortsFlyoutMenuVisible = false;
+ } catch (Exception ex) {
+ Logger.printException(() -> "onFlyoutMenuCreate failure", ex);
+ }
+ });
+ }
+
+ private static void hideFlyoutMenu() {
+ if (!SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU_ENABLED) {
+ return;
+ }
+ RecyclerView recyclerView = recyclerViewRef.get();
+ if (recyclerView == null) {
+ return;
+ }
+
+ if (!(Utils.getParentView(recyclerView, 3) instanceof ViewGroup parentView3rd)) {
+ return;
+ }
+
+ if (!(parentView3rd.getParent() instanceof ViewGroup parentView4th)) {
+ return;
+ }
+
+ // Dismiss View [R.id.touch_outside] is the 1st ChildView of the 4th ParentView.
+ // This only shows in phone layout.
+ Utils.clickView(parentView4th.getChildAt(0));
+
+ // In tablet layout there is no Dismiss View, instead we just hide all two parent views.
+ parentView3rd.setVisibility(View.GONE);
+ parentView4th.setVisibility(View.GONE);
+ }
+
+ public enum CustomAction {
+ COPY_URL(
+ Settings.SHORTS_CUSTOM_ACTIONS_COPY_VIDEO_URL,
+ "yt_outline_link_black_24",
+ () -> VideoUtils.copyUrl(
+ VideoUtils.getVideoUrl(
+ ShortsCustomActionsFilter.getShortsVideoId(),
+ false
+ ),
+ false
+ ),
+ () -> VideoUtils.copyUrl(
+ VideoUtils.getVideoUrl(
+ ShortsCustomActionsFilter.getShortsVideoId(),
+ true
+ ),
+ true
+ )
+ ),
+ COPY_URL_WITH_TIMESTAMP(
+ Settings.SHORTS_CUSTOM_ACTIONS_COPY_VIDEO_URL_TIMESTAMP,
+ "yt_outline_arrow_time_black_24",
+ () -> VideoUtils.copyUrl(
+ VideoUtils.getVideoUrl(
+ ShortsCustomActionsFilter.getShortsVideoId(),
+ true
+ ),
+ true
+ ),
+ () -> VideoUtils.copyUrl(
+ VideoUtils.getVideoUrl(
+ ShortsCustomActionsFilter.getShortsVideoId(),
+ false
+ ),
+ false
+ )
+ ),
+ EXTERNAL_DOWNLOADER(
+ Settings.SHORTS_CUSTOM_ACTIONS_EXTERNAL_DOWNLOADER,
+ "yt_outline_download_black_24",
+ () -> VideoUtils.launchVideoExternalDownloader(
+ ShortsCustomActionsFilter.getShortsVideoId()
+ )
+ ),
+ OPEN_VIDEO(
+ Settings.SHORTS_CUSTOM_ACTIONS_OPEN_VIDEO,
+ "yt_outline_youtube_logo_icon_black_24",
+ () -> VideoUtils.openVideo(
+ ShortsCustomActionsFilter.getShortsVideoId(),
+ true
+ )
+ ),
+ REPEAT_STATE(
+ Settings.SHORTS_CUSTOM_ACTIONS_REPEAT_STATE,
+ "yt_outline_arrow_repeat_1_black_24",
+ () -> VideoUtils.showShortsRepeatDialog(contextRef.get())
+ );
+
+ @NonNull
+ private final BooleanSetting settings;
+
+ @NonNull
+ private final Drawable drawable;
+
+ @NonNull
+ private final String label;
+
+ @NonNull
+ private final Runnable onClickAction;
+
+ @Nullable
+ private final Runnable onLongClickAction;
+
+ CustomAction(@NonNull BooleanSetting settings,
+ @NonNull String icon,
+ @NonNull Runnable onClickAction
+ ) {
+ this(settings, icon, onClickAction, null);
+ }
+
+ CustomAction(@NonNull BooleanSetting settings,
+ @NonNull String icon,
+ @NonNull Runnable onClickAction,
+ @Nullable Runnable onLongClickAction
+ ) {
+ this.drawable = Objects.requireNonNull(ResourceUtils.getDrawable(icon));
+ this.label = getString(settings.key + "_label");
+ this.settings = settings;
+ this.onClickAction = onClickAction;
+ this.onLongClickAction = onLongClickAction;
+ }
+
+ @NonNull
+ public Drawable getDrawable() {
+ return drawable;
+ }
+
+ @NonNull
+ public String getLabel() {
+ return label;
+ }
+
+ @NonNull
+ public Runnable getOnClickAction() {
+ return onClickAction;
+ }
+
+ @NonNull
+ public View.OnClickListener getOnClickListener() {
+ return v -> {
+ hideFlyoutMenu();
+ onClickAction.run();
+ };
+ }
+
+ @Nullable
+ public View.OnLongClickListener getOnLongClickListener() {
+ if (onLongClickAction == null) {
+ return null;
+ } else {
+ return v -> {
+ hideFlyoutMenu();
+ onLongClickAction.run();
+ return true;
+ };
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
index a85472821..c76ab9a15 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
@@ -464,10 +464,23 @@ public class Settings extends BaseSettings {
public static final BooleanSetting HIDE_SHORTS_SHARE_BUTTON = new BooleanSetting("revanced_hide_shorts_share_button", FALSE);
public static final BooleanSetting HIDE_SHORTS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_sound_button", TRUE);
+ // PreferenceScreen: Shorts - Shorts player components - Animation / Feedback
public static final BooleanSetting DISABLE_SHORTS_LIKE_BUTTON_FOUNTAIN_ANIMATION = new BooleanSetting("revanced_disable_shorts_like_button_fountain_animation", FALSE);
public static final BooleanSetting HIDE_SHORTS_PLAY_PAUSE_BUTTON_BACKGROUND = new BooleanSetting("revanced_hide_shorts_play_pause_button_background", FALSE, true);
public static final EnumSetting ANIMATION_TYPE = new EnumSetting<>("revanced_shorts_double_tap_to_like_animation", AnimationType.ORIGINAL, true);
+ // PreferenceScreen: Shorts - Shorts player components - Custom actions
+ public static final BooleanSetting ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU = new BooleanSetting("revanced_enable_shorts_custom_actions_flyout_menu", FALSE, true);
+ public static final BooleanSetting SHORTS_CUSTOM_ACTIONS_COPY_VIDEO_URL = new BooleanSetting("revanced_shorts_custom_actions_copy_video_url", FALSE, true,
+ parentsAny(ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU));
+ public static final BooleanSetting SHORTS_CUSTOM_ACTIONS_COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_shorts_custom_actions_copy_video_url_timestamp", FALSE, true,
+ parentsAny(ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU));
+ public static final BooleanSetting SHORTS_CUSTOM_ACTIONS_EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_shorts_custom_actions_external_downloader", FALSE, true,
+ parentsAny(ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU));
+ public static final BooleanSetting SHORTS_CUSTOM_ACTIONS_OPEN_VIDEO = new BooleanSetting("revanced_shorts_custom_actions_open_video", FALSE, true,
+ parentsAny(ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU));
+ public static final BooleanSetting SHORTS_CUSTOM_ACTIONS_REPEAT_STATE = new BooleanSetting("revanced_shorts_custom_actions_repeat_state", FALSE, true,
+ parentsAny(ENABLE_SHORTS_CUSTOM_ACTIONS_FLYOUT_MENU));
// Experimental Flags
public static final BooleanSetting ENABLE_TIME_STAMP = new BooleanSetting("revanced_enable_shorts_time_stamp", FALSE, true);
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java
index 485d64cd2..686e105fd 100644
--- a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java
@@ -31,7 +31,8 @@ import app.revanced.extension.youtube.shared.VideoInformation;
public class VideoUtils extends IntentUtils {
private static final String PLAYLIST_URL = "https://www.youtube.com/playlist?list=";
private static final String VIDEO_URL = "https://youtu.be/";
- private static final String VIDEO_SCHEME_FORMAT = "vnd.youtube://%s?start=%d";
+ private static final String VIDEO_SCHEME_INTENT_FORMAT = "vnd.youtube://%s?start=%d";
+ private static final String VIDEO_SCHEME_LINK_FORMAT = "https://youtu.be/%s?t=%d";
private static final AtomicBoolean isExternalDownloaderLaunched = new AtomicBoolean(false);
private static String getPlaylistUrl(String playlistId) {
@@ -46,7 +47,7 @@ public class VideoUtils extends IntentUtils {
return getVideoUrl(VideoInformation.getVideoId(), withTimestamp);
}
- private static String getVideoUrl(String videoId, boolean withTimestamp) {
+ public static String getVideoUrl(String videoId, boolean withTimestamp) {
StringBuilder builder = new StringBuilder(VIDEO_URL);
builder.append(videoId);
final long currentVideoTimeInSeconds = VideoInformation.getVideoTimeInSeconds();
@@ -58,15 +59,24 @@ public class VideoUtils extends IntentUtils {
}
private static String getVideoScheme() {
- return getVideoScheme(VideoInformation.getVideoId());
+ return getVideoScheme(VideoInformation.getVideoId(), false);
}
- private static String getVideoScheme(String videoId) {
- return String.format(Locale.ENGLISH, VIDEO_SCHEME_FORMAT, videoId, VideoInformation.getVideoTimeInSeconds());
+ private static String getVideoScheme(String videoId, boolean isShorts) {
+ return String.format(
+ Locale.ENGLISH,
+ isShorts ? VIDEO_SCHEME_INTENT_FORMAT : VIDEO_SCHEME_LINK_FORMAT,
+ videoId,
+ VideoInformation.getVideoTimeInSeconds()
+ );
}
public static void copyUrl(boolean withTimestamp) {
- setClipboard(getVideoUrl(withTimestamp), withTimestamp
+ copyUrl(getVideoUrl(withTimestamp), withTimestamp);
+ }
+
+ public static void copyUrl(String videoUrl, boolean withTimestamp) {
+ setClipboard(videoUrl, withTimestamp
? str("revanced_share_copy_url_timestamp_success")
: str("revanced_share_copy_url_success")
);
@@ -118,7 +128,11 @@ public class VideoUtils extends IntentUtils {
}
public static void openVideo(@NonNull String videoId) {
- openVideo(getVideoScheme(videoId), "");
+ openVideo(getVideoScheme(videoId, false), "");
+ }
+
+ public static void openVideo(@NonNull String videoId, boolean isShorts) {
+ openVideo(getVideoScheme(videoId, isShorts), "");
}
public static void openVideo(@NonNull PlaylistIdPrefix prefixId) {
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatch.kt
index 0223783bc..4da47d69b 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/FeedFlyoutMenuPatch.kt
@@ -5,16 +5,17 @@ import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.bytecodePatch
+import app.revanced.patches.youtube.utils.bottomSheetMenuItemBuilderFingerprint
import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.youtube.utils.extension.Constants.FEED_CLASS_DESCRIPTOR
+import app.revanced.patches.youtube.utils.indexOfSpannedCharSequenceInstruction
import app.revanced.patches.youtube.utils.patch.PatchList.HIDE_FEED_FLYOUT_MENU
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
-import app.revanced.util.fingerprint.matchOrNull
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.formats.Instruction35c
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
@@ -33,27 +34,16 @@ val feedFlyoutMenuPatch = bytecodePatch(
// region patch for phone
- val bottomSheetMenuItemBuilderMatch =
- bottomSheetMenuItemBuilderLegacyFingerprint.matchOrNull()
- ?: bottomSheetMenuItemBuilderFingerprint.matchOrThrow()
+ bottomSheetMenuItemBuilderFingerprint.methodOrThrow().apply {
+ val insertIndex = indexOfSpannedCharSequenceInstruction(this) + 2
+ val insertRegister = getInstruction(insertIndex - 1).registerA
- bottomSheetMenuItemBuilderMatch.let {
- it.method.apply {
- val targetIndex = it.patternMatch!!.endIndex
- val targetRegister = getInstruction(targetIndex).registerA
-
- val targetParameter =
- getInstruction(targetIndex - 1).reference
- if (!targetParameter.toString().endsWith("Ljava/lang/CharSequence;"))
- throw PatchException("Method signature parameter did not match: $targetParameter")
-
- addInstructions(
- targetIndex + 1, """
- invoke-static {v$targetRegister}, $FEED_CLASS_DESCRIPTOR->hideFlyoutMenu(Ljava/lang/CharSequence;)Ljava/lang/CharSequence;
- move-result-object v$targetRegister
- """
- )
- }
+ addInstructions(
+ insertIndex, """
+ invoke-static {v$insertRegister}, $FEED_CLASS_DESCRIPTOR->hideFlyoutMenu(Ljava/lang/CharSequence;)Ljava/lang/CharSequence;
+ move-result-object v$insertRegister
+ """
+ )
}
// endregion
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/Fingerprints.kt
index 2c905c376..13b95fe72 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/Fingerprints.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/feed/flyoutmenu/Fingerprints.kt
@@ -6,39 +6,6 @@ import app.revanced.util.or
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
-/**
- * Compatible with YouTube v19.11.43~
- */
-internal val bottomSheetMenuItemBuilderFingerprint = legacyFingerprint(
- name = "bottomSheetMenuItemBuilderFingerprint",
- accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
- returnType = "L",
- parameters = listOf("L"),
- opcodes = listOf(
- Opcode.INVOKE_STATIC,
- Opcode.MOVE_RESULT_OBJECT,
- Opcode.INVOKE_STATIC,
- Opcode.MOVE_RESULT_OBJECT
- ),
- strings = listOf("Text missing for BottomSheetMenuItem with iconType: ")
-)
-
-/**
- * Compatible with ~YouTube v19.10.39
- */
-internal val bottomSheetMenuItemBuilderLegacyFingerprint = legacyFingerprint(
- name = "bottomSheetMenuItemBuilderLegacyFingerprint",
- returnType = "L",
- parameters = listOf("L"),
- opcodes = listOf(
- Opcode.INVOKE_STATIC,
- Opcode.MOVE_RESULT_OBJECT,
- Opcode.INVOKE_STATIC,
- Opcode.MOVE_RESULT_OBJECT
- ),
- strings = listOf("ElementTransformer, ElementPresenter and InteractionLogger cannot be null")
-)
-
internal val contextualMenuItemBuilderFingerprint = legacyFingerprint(
name = "contextualMenuItemBuilderFingerprint",
returnType = "V",
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/Fingerprints.kt
index fae0d2e8d..831ab3913 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/Fingerprints.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/Fingerprints.kt
@@ -1,7 +1,6 @@
package app.revanced.patches.youtube.shorts.components
import app.revanced.patches.youtube.utils.resourceid.badgeLabel
-import app.revanced.patches.youtube.utils.resourceid.bottomBarContainer
import app.revanced.patches.youtube.utils.resourceid.metaPanel
import app.revanced.patches.youtube.utils.resourceid.reelDynRemix
import app.revanced.patches.youtube.utils.resourceid.reelDynShare
@@ -19,14 +18,18 @@ import app.revanced.util.or
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
-// multiFingerprint
-internal val bottomBarContainerHeightFingerprint = legacyFingerprint(
- name = "bottomBarContainerHeightFingerprint",
- returnType = "V",
- accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
- parameters = listOf("Landroid/view/View;", "Landroid/os/Bundle;"),
- strings = listOf("r_pfvc"),
- literals = listOf(bottomBarContainer),
+internal val bottomSheetMenuListBuilderFingerprint = legacyFingerprint(
+ name = "bottomSheetMenuListBuilderFingerprint",
+ returnType = "L",
+ accessFlags = AccessFlags.PROTECTED or AccessFlags.FINAL,
+ parameters = emptyList(),
+ opcodes = listOf(
+ Opcode.IGET_OBJECT,
+ Opcode.INVOKE_VIRTUAL,
+ Opcode.MOVE_RESULT,
+ Opcode.IF_EQZ,
+ ),
+ strings = listOf("Bottom Sheet Menu is empty. No menu items were supported."),
)
internal val reelEnumConstructorFingerprint = legacyFingerprint(
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/ShortsComponentPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/ShortsComponentPatch.kt
index 8f60b0687..5a7ef0ab6 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/ShortsComponentPatch.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/shorts/components/ShortsComponentPatch.kt
@@ -6,6 +6,7 @@ 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.extensions.InstructionExtensions.removeInstruction
+import app.revanced.patcher.extensions.InstructionExtensions.removeInstructions
import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod
@@ -14,11 +15,13 @@ import app.revanced.patches.shared.litho.addLithoFilter
import app.revanced.patches.shared.litho.lithoFilterPatch
import app.revanced.patches.shared.textcomponent.hookSpannableString
import app.revanced.patches.shared.textcomponent.textComponentPatch
+import app.revanced.patches.youtube.utils.bottomSheetMenuItemBuilderFingerprint
import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH
import app.revanced.patches.youtube.utils.extension.Constants.SHORTS_CLASS_DESCRIPTOR
import app.revanced.patches.youtube.utils.extension.Constants.SHORTS_PATH
import app.revanced.patches.youtube.utils.extension.Constants.UTILS_PATH
+import app.revanced.patches.youtube.utils.indexOfSpannedCharSequenceInstruction
import app.revanced.patches.youtube.utils.lottie.LOTTIE_ANIMATION_VIEW_CLASS_DESCRIPTOR
import app.revanced.patches.youtube.utils.lottie.lottieAnimationViewHookPatch
import app.revanced.patches.youtube.utils.navigation.addBottomBarContainerHook
@@ -26,9 +29,12 @@ import app.revanced.patches.youtube.utils.navigation.navigationBarHookPatch
import app.revanced.patches.youtube.utils.patch.PatchList.SHORTS_COMPONENTS
import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch
import app.revanced.patches.youtube.utils.playservice.is_18_31_or_greater
+import app.revanced.patches.youtube.utils.playservice.is_18_49_or_greater
import app.revanced.patches.youtube.utils.playservice.is_19_25_or_greater
import app.revanced.patches.youtube.utils.playservice.is_19_28_or_greater
import app.revanced.patches.youtube.utils.playservice.versionCheckPatch
+import app.revanced.patches.youtube.utils.recyclerview.bottomSheetRecyclerViewHook
+import app.revanced.patches.youtube.utils.recyclerview.bottomSheetRecyclerViewPatch
import app.revanced.patches.youtube.utils.resourceid.bottomBarContainer
import app.revanced.patches.youtube.utils.resourceid.metaPanel
import app.revanced.patches.youtube.utils.resourceid.reelDynRemix
@@ -49,9 +55,13 @@ import app.revanced.patches.youtube.utils.settings.ResourceUtils.getContext
import app.revanced.patches.youtube.utils.settings.settingsPatch
import app.revanced.patches.youtube.video.information.hookShortsVideoInformation
import app.revanced.patches.youtube.video.information.videoInformationPatch
+import app.revanced.patches.youtube.video.videoid.hookPlayerResponseVideoId
+import app.revanced.patches.youtube.video.videoid.videoIdPatch
import app.revanced.util.REGISTER_TEMPLATE_REPLACEMENT
import app.revanced.util.ResourceGroup
+import app.revanced.util.cloneMutable
import app.revanced.util.copyResources
+import app.revanced.util.findMethodOrThrow
import app.revanced.util.findMutableMethodOf
import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall
import app.revanced.util.fingerprint.matchOrThrow
@@ -73,6 +83,7 @@ import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
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.RegisterRangeInstruction
import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
@@ -130,6 +141,153 @@ private val shortsAnimationPatch = bytecodePatch(
}
}
+private const val SHORTS_PLAYER_FLYOUT_MENU_FILTER_CLASS_DESCRIPTOR =
+ "$COMPONENTS_PATH/ShortsCustomActionsFilter;"
+private const val EXTENSION_CUSTOM_ACTIONS_CLASS_DESCRIPTOR =
+ "$SHORTS_PATH/CustomActionsPatch;"
+
+private val shortsCustomActionsPatch = bytecodePatch(
+ description = "shortsCustomActionsPatch"
+) {
+ dependsOn(
+ bottomSheetRecyclerViewPatch,
+ lithoFilterPatch,
+ playerTypeHookPatch,
+ videoIdPatch,
+ videoInformationPatch,
+ versionCheckPatch,
+ )
+
+ execute {
+ if (!is_18_49_or_greater) {
+ return@execute
+ }
+
+ bottomSheetMenuListBuilderFingerprint.matchOrThrow().let {
+ it.method.apply {
+ val addListIndex = indexOfFirstInstructionOrThrow {
+ opcode == Opcode.INVOKE_VIRTUAL &&
+ getReference()?.name == "add"
+ }
+ val addListReference = getInstruction(addListIndex).reference
+
+ val getObjectIndex = indexOfFirstInstructionReversedOrThrow(addListIndex) {
+ opcode == Opcode.INVOKE_VIRTUAL &&
+ getReference()?.returnType == "Ljava/lang/Object;"
+ }
+ val getObjectReference = getInstruction(getObjectIndex).reference as MethodReference
+
+ val bottomSheetMenuInitializeIndex = indexOfFirstInstructionOrThrow {
+ val reference = getReference()
+ opcode == Opcode.INVOKE_STATIC_RANGE &&
+ reference?.returnType == "V" &&
+ reference.parameterTypes[1] == "Ljava/lang/Object;"
+ }
+ val bottomSheetMenuObjectRegister = getInstruction(bottomSheetMenuInitializeIndex).startRegister
+ val bottomSheetMenuObject = (getInstruction(bottomSheetMenuInitializeIndex).reference as MethodReference).parameterTypes[0]!!
+
+ val bottomSheetMenuListIndex = it.patternMatch!!.startIndex
+ val bottomSheetMenuListField = (getInstruction(bottomSheetMenuListIndex).reference as FieldReference)
+
+ val bottomSheetMenuClass = bottomSheetMenuListField.definingClass
+ val bottomSheetMenuList = bottomSheetMenuListField.type
+
+ val bottomSheetMenuClassRegister = getInstruction(bottomSheetMenuListIndex).registerB
+ val bottomSheetMenuListRegister = getInstruction(bottomSheetMenuListIndex).registerA
+
+ addInstruction(
+ bottomSheetMenuListIndex + 1,
+ "invoke-static {v$bottomSheetMenuClassRegister, v$bottomSheetMenuListRegister}, " +
+ "$EXTENSION_CUSTOM_ACTIONS_CLASS_DESCRIPTOR->addFlyoutMenu(Ljava/lang/Object;Ljava/lang/Object;)V"
+ )
+
+ addInstruction(
+ bottomSheetMenuInitializeIndex + 1,
+ "invoke-static {v$bottomSheetMenuObjectRegister}, " +
+ "$EXTENSION_CUSTOM_ACTIONS_CLASS_DESCRIPTOR->setFlyoutMenuObject(Ljava/lang/Object;)V"
+ )
+
+ val addFlyoutMenuMethod = findMethodOrThrow(EXTENSION_CUSTOM_ACTIONS_CLASS_DESCRIPTOR) {
+ name == "addFlyoutMenu" &&
+ accessFlags == AccessFlags.PRIVATE or AccessFlags.STATIC
+ }
+
+ val customActionClass = with(addFlyoutMenuMethod) {
+ val thirdParameter = parameters[2]
+
+ addInstructions(
+ 3, """
+ check-cast p0, $bottomSheetMenuClass
+ check-cast v0, $bottomSheetMenuObject
+ invoke-virtual {p0, v0, p2}, $bottomSheetMenuClass->buildFlyoutMenu(${bottomSheetMenuObject}${thirdParameter})${getObjectReference.definingClass}
+ move-result-object v0
+ invoke-virtual {v0}, $getObjectReference
+ move-result-object v0
+ check-cast p1, $bottomSheetMenuList
+ invoke-virtual {p1, v0}, $addListReference
+ return-void
+ """
+ )
+
+ thirdParameter
+ }
+
+ val bottomSheetMenuItemBuilderMethod = bottomSheetMenuItemBuilderFingerprint
+ .methodOrThrow()
+
+ val newParameter = bottomSheetMenuItemBuilderMethod.parameters + listOf(customActionClass)
+
+ it.classDef.methods.add(
+ bottomSheetMenuItemBuilderMethod
+ .cloneMutable(
+ accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
+ name = "buildFlyoutMenu",
+ registerCount = bottomSheetMenuItemBuilderMethod.implementation!!.registerCount + 1,
+ parameters = newParameter,
+ ).apply {
+ val drawableIndex = indexOfFirstInstructionOrThrow {
+ opcode == Opcode.INVOKE_DIRECT &&
+ getReference()?.returnType == "Landroid/graphics/drawable/Drawable;"
+ }
+ val drawableRegister = getInstruction(drawableIndex + 1).registerA
+
+ addInstructions(
+ drawableIndex + 2, """
+ invoke-virtual {p2}, $customActionClass->getDrawable()Landroid/graphics/drawable/Drawable;
+ move-result-object v$drawableRegister
+ """
+ )
+
+ val charSequenceIndex = indexOfSpannedCharSequenceInstruction(this)
+ val charSequenceRegister = getInstruction(charSequenceIndex + 1).registerA
+
+ val insertIndex = charSequenceIndex + 2
+
+ if (getInstruction(insertIndex).reference.toString().startsWith("Lapp/revanced")) {
+ removeInstructions(insertIndex, 2)
+ }
+
+ addInstructions(
+ insertIndex, """
+ invoke-virtual {p2}, $customActionClass->getLabel()Ljava/lang/String;
+ move-result-object v$charSequenceRegister
+ """
+ )
+ }
+ )
+ }
+ }
+
+ bottomSheetRecyclerViewHook("$EXTENSION_CUSTOM_ACTIONS_CLASS_DESCRIPTOR->onFlyoutMenuCreate(Landroid/support/v7/widget/RecyclerView;)V")
+
+ hookPlayerResponseVideoId("$SHORTS_PLAYER_FLYOUT_MENU_FILTER_CLASS_DESCRIPTOR->newPlayerResponseVideoId(Ljava/lang/String;Z)V")
+ hookShortsVideoInformation("$SHORTS_PLAYER_FLYOUT_MENU_FILTER_CLASS_DESCRIPTOR->newShortsVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V")
+
+ addLithoFilter(SHORTS_PLAYER_FLYOUT_MENU_FILTER_CLASS_DESCRIPTOR)
+
+ }
+}
+
private val shortsNavigationBarPatch = bytecodePatch(
description = "shortsNavigationBarPatch"
) {
@@ -344,6 +502,7 @@ val shortsComponentPatch = bytecodePatch(
dependsOn(
shortsAnimationPatch,
+ shortsCustomActionsPatch,
shortsNavigationBarPatch,
shortsRepeatPatch,
shortsTimeStampPatch,
@@ -412,6 +571,10 @@ val shortsComponentPatch = bytecodePatch(
settingArray += "SETTINGS: SHORTS_TIME_STAMP"
}
+ if (is_18_49_or_greater) {
+ settingArray += "SETTINGS: SHORTS_CUSTOM_ACTIONS"
+ }
+
// region patch for hide comments button (non-litho)
shortsButtonFingerprint.hideButton(rightComment, "hideShortsCommentsButton", false)
diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/Fingerprints.kt
index c0bfcae87..c57531270 100644
--- a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/Fingerprints.kt
+++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/Fingerprints.kt
@@ -21,6 +21,31 @@ import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.Method
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
+internal val bottomSheetMenuItemBuilderFingerprint = legacyFingerprint(
+ name = "bottomSheetMenuItemBuilderFingerprint",
+ accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
+ returnType = "L",
+ parameters = listOf("L"),
+ opcodes = listOf(
+ Opcode.INVOKE_STATIC,
+ Opcode.MOVE_RESULT_OBJECT,
+ Opcode.INVOKE_STATIC,
+ Opcode.MOVE_RESULT_OBJECT
+ ),
+ strings = listOf("Text missing for BottomSheetMenuItem."),
+ customFingerprint = { method, _ ->
+ indexOfSpannedCharSequenceInstruction(method) >= 0
+ }
+)
+
+fun indexOfSpannedCharSequenceInstruction(method: Method) =
+ method.indexOfFirstInstruction {
+ val reference = getReference()
+ opcode == Opcode.INVOKE_STATIC &&
+ reference?.parameterTypes?.size == 1 &&
+ reference.returnType == "Ljava/lang/CharSequence;"
+ }
+
internal val engagementPanelBuilderFingerprint = legacyFingerprint(
name = "engagementPanelBuilderFingerprint",
returnType = "L",
diff --git a/patches/src/main/resources/youtube/settings/host/values/strings.xml b/patches/src/main/resources/youtube/settings/host/values/strings.xml
index 4222dea96..3d09c0b93 100644
--- a/patches/src/main/resources/youtube/settings/host/values/strings.xml
+++ b/patches/src/main/resources/youtube/settings/host/values/strings.xml
@@ -1041,7 +1041,7 @@ Tap and hold to copy video URL with timestamp."
Tap and hold to copy video timestamp."
Show mute volume button
Tap to mute volume of the current video. Tap again to unmute.
- Show external download button
+ Show external downloader button
Tap to launch external downloader.
Show speed dialog button
"Tap to open speed dialog.
@@ -1354,6 +1354,37 @@ Info:
Heart (Tint)
Hidden
+
+ Custom actions
+ Enable custom actions in flyout menu
+ "Custom actions are enabled in flyout menu.
+
+Limitations:
+• Does not work if app version is spoofed to 18.49.37 or earlier.
+• Does not work with livestream."
+ Custom actions are disabled in flyout menu.
+ Custom actions
+ Copy video URL
+ Show copy video URL menu
+ Copy video URL menu is shown.
+ Copy video URL menu is hidden.
+ Copy timestamp URL
+ Show copy timestamp URL menu
+ Copy timestamp URL menu is shown.
+ Copy timestamp URL menu is hidden.
+ External downloader
+ Show external downloader menu
+ External downloader menu is shown.
+ External downloader menu is hidden.
+ Open video
+ Show open video menu
+ Open video menu is shown.
+ Open video menu is hidden.
+ Repeat state
+ Show repeat state menu
+ Repeat state menu is shown.
+ Repeat state menu is hidden.
+
Enable timestamps
"Timestamp is enabled.
diff --git a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml
index f6e780376..9de166ae6 100644
--- a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml
+++ b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml
@@ -580,9 +580,18 @@
-
+ SETTINGS: SHORTS_COMPONENTS -->
- SETTINGS: SHORTS_COMPONENTS -->
+